Skip to content

Synthesis: place three positions, compare every four-bar they admit, insert one - #243

Merged
KohmeiK merged 30 commits into
multi-mechanism-redesignfrom
synthesis-redesign
Aug 22, 2026
Merged

KohmeiK merged 30 commits into
multi-mechanism-redesignfrom
synthesis-redesign

Conversation

@KohmeiK

@KohmeiK KohmeiK commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Implements the Synthesis Prototype.dc.html design from the Claude Design project.

Retargeted to multi-mechanism-redesign now that #241 has merged.

What changes

Synthesis used to build one four-bar onto the grid and rebuild it on every nudge of a coordinate. That made the one thing the mode is for impossible: looking at a second solution destroyed the first. It is now a search.

Before After
Flow poses → builds immediately chooser → place → Generate → browse → Insert
Solutions exactly one up to 8, ranked, compared side by side
Reach marks full kinematic solve against a tolerance driveable-to, on one assembly, without stalling
Placing "Create Pose" drops at (0, 0) arm, scroll to turn the ghost, click to drop
Pose handles X/Y arrows + rotation circle body, one length grip, one turn knob
Panel 250 px 400 px, accordion sections
Persistence none design rides in the URL — undoable and shareable

Where the extra solutions come from. Three positions of a rigid body fix three positions of any point on it, and three points determine a circle — so that circle's centre is a ground pin it can be pinned to. Doing that for the link's two ends is the one construction synthesis has always done. The coupler does not have to be pinned at the ends: sliding the pins along the link moves both circle centres and gives genuinely different machines through the same three positions.

Bugs found and fixed along the way

  • "Reaches all 3" meant solvable at, not driveable to. The loop can close at crank angles the crank cannot reach — the circles intersect again on a stretch reachable only by taking the linkage apart, which is what a branch defect is. Reach and travel were computed separately and never compared.
  • A linkage can reach a position and stall on it. At a dead point the transmission angle goes to zero and no force turns it. Solutions under 15° are no longer offered as reaching all three.
  • The six-bar preview was driving the wrong crank. With a driver fitted the input is the driver's crank; the four-bar's is an output. Stepping the wrong one left four degrees of travel on one design. Now solved forwards, as the inserted mechanism is.
  • The coupler trace ruled straight lines across gaps, for the same reason — it sampled the four-bar over the driver's range.
  • The pan guard recognised synthesis by what was last clicked, which never stopped being a pose, so the canvas could not be panned again until something else was selected.
  • A position was only placed by a click released within 100 ms — pastDragThreshold calls a slow press a drag, which is right for a part on the grid and wrong for aiming at an empty spot.
  • Decoding a URL did not invalidate the search, so candidates for a previous design could outlive it.
  • Section dividers rendered at 2px — each section carried a bottom border and each header a top one.

Verification

  • 323 unit tests, including synthesis-driveable.spec.ts, which drives 400 random designs rather than asking them. Falsified: reverting the reachability fix makes it fail on a named design.
  • 81/81 browser checks in e2e/synthesis-redesign.mjs.
  • The preview's coupler path agrees with the frames the app's own solver produces, once inserted, to within 11 internal units (0.04 cm) in both directions.
  • Existing URL specs still compare the same bytes: a document with no design in progress encodes exactly as before.
  • Production build clean.

🤖 Generated with Claude Code

KohmeiK and others added 7 commits August 20, 2026 17:26
Three positions of a rigid body fix three positions of every point on it, and
three points determine a circle -- so the centre of that circle is a ground
pivot the point can be pinned to. Synthesis has always done that twice, for the
two ends of the end-effector link, which is why it has always had exactly one
answer.

The coupler does not have to be pinned at the ends. Sliding the two pins along
the link, or past it, moves both circle centres and gives a genuinely different
machine through the same three positions. Enumerating those turns synthesis
from "here is the answer" into "here are the answers, compare them".

Each one is then asked the question the construction cannot answer for itself.
It closes at all three positions by definition; whether it reaches all three on
*one assembly* is a different matter, and a linkage that has to be taken apart
between two of them is useless as a machine however exactly it fits. That is a
branch defect, and it is the first thing a reader comparing candidates needs to
be told.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Synthesis used to write a four-bar onto the grid on every nudge of a
coordinate. That made the one thing this mode is for impossible: looking at a
second solution destroyed the first, so there was never anything to compare.

So the design and the answer are now two things. SynthesisBuilderService holds
what was asked for -- three positions, the coupler they belong to, and what a
solution has to satisfy -- and gains the state the redesign needs: which screen
the reader is on, whether the next click drops a position and which way it is
turned, and the ground-pivot region. SynthesisSolutionService holds what came
back, and touches the grid exactly once, when Insert says so.

Positions are placed rather than created at the origin, so removing one closes
the gap it leaves: the panel fills three numbered rows in order, and a hole in
the middle is a state the gesture cannot produce and cannot repair.

The pose gizmo's old parts go with it. The X/Y arrows, the rotation circle and
the status colours were the whole of SynthesisConstants and most of
SynthesisPose; the handles that replace them are drawn from the design itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel used to be a form: type a length, create three poses, and a linkage
appeared. It is now the account of a search. What is being synthesised, the
coupler, the three positions as rows that read the same before and after they
are filled, what a solution has to satisfy, an explicit Generate, the
candidates side by side with what each one can and cannot do, and the one
button that puts a solution in the drawing.

Every requirement says what it is costing rather than only what it is: switched
on it narrows the search, off it widens it, and when nothing is found the panel
names the one to relax rather than leaving the reader to guess. The
purely-geometric explanation is kept for when no requirement is in the way,
because with one switched on it is the wrong answer.

Its width goes to 400px, the analysis panel's. Three numbers per position, a
gallery read side by side and a transport do not fit in 250, and all three are
comparisons that stop working the moment they wrap. The stylesheet ships
through the theme mixin nested under the panel's own id: it is twice the
component-style budget, and half its class names are words rather than names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Placing is opt-in: Add position arms the canvas, the ghost follows the pointer,
the wheel turns it, and a click drops it. Synthesis shares the canvas with the
drawing, and a mode where every click makes something is a mode where every
click meant as "look at this" makes something instead.

A placed position wears the dashed box, corner grips and turn knob the tracing
underlay wears, because it is the same kind of gesture -- something being
placed on the grid rather than built. Its corners pull the one dimension a
position has: how long the end-effector is.

The chosen candidate is previewed live, tinting each position by what it does
with it, and hovering another keeps the picked one on screen faded behind it --
without that, moving along the gallery replaces the linkage with no way to see
what it replaced, which is the one comparison the gallery exists to make.

Three things had to give way for the gestures to work. The pan guard used to
recognise synthesis by what was last clicked, which never stopped being a pose,
so the canvas could not be panned again until something else was selected; it
now asks whether a gesture is actually in flight. The wheel is handed over
through svg-pan-zoom's own API while a position is waiting to be dropped,
because swallowing the event depends on which listener was registered first.
And it is handed back from one place, since a missed pairing leaves the canvas
with a dead wheel until the tab is left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Undo and redo are a stack of URL strings. A design that is not written into
them cannot be stepped back through -- and now that synthesis holds work of its
own for as long as it takes to compare seven linkages, that work is exactly the
kind a reader expects Undo to reach. A shared link mid-design opened on an
empty panel for the same reason.

It rides in the trailing section the lock marks opened, tagged 'S', which no
lock or centre-of-mass anchor uses. Written only when there is a design to
write, so a document with none encodes to the bytes it did before any of this
existed -- the same bargain that section was built on, and the existing URL
specs still compare the same strings.

Unlike a lock or an anchor it names nothing the URL carries, so there is no
reference to resolve; what it can be checked against is its own shape. It fails
closed on an entry that is short a number or carries a tag nobody wrote,
because a design half-read is worse than none: the panel would come up with
positions that are not where the reader left them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… six-bar

The suite it replaces checked that synthesis added a mechanism rather than
replacing the drawing -- a promise about a build that no longer happens on its
own. What is worth checking now is mostly the machinery around the new promise:
that the canvas gestures do not fight svg-pan-zoom, that nothing reaches the
drawing before Insert, that the preview stops being drawn once the real thing
is there, and that the design survives undo and redo.

Selectors are scoped to the panel's own id. Half these class names are words
rather than names -- card, row, note -- and the app has its own elements
wearing them: an unscoped `.card` matched six elements and quietly clicked the
wrong one, which read as two features being broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing else is

The status strip reports on the drawing, and in Synthesis the drawing is not
what the reader is working on: a design in progress is not on it at all, so
"Nothing to analyse yet" was true and useless for the whole of the work. It now
says where in the search they are -- which position is about to be dropped, that
three are placed and ready, which solution is being looked at and what it does
with the positions -- and after Insert it says what was left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for pmksprod ready!

Name Link
🔨 Latest commit 9e1451b
🔍 Latest deploy log https://app.netlify.com/projects/pmksprod/deploys/6a88e5f94651af0008250559
😎 Deploy Preview https://deploy-preview-243--pmksprod.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

KohmeiK and others added 2 commits August 20, 2026 18:42
The one that mattered: a position was dropped only on a press released within
a tenth of a second. `pastDragThreshold` calls a slow press a drag, which is
right for a part already on the grid -- holding still is how you take hold of
something -- and wrong for aiming at an empty spot, where a deliberate click is
the normal gesture. Every slower click was thrown away, which is why placing a
position seemed to need several. Distance is the only thing that tells the two
apart here.

The rest:

- Generate reports itself for at least a second. The search is real work but on
  a small design it finishes inside one frame, and a button that answers that
  fast reads as though nothing happened. The floor is under the progress state,
  not the work: a slower search simply takes longer.
- No position row is selected until one is asked for. A highlighted row before
  anything is placed reads as "your first position went here", which is exactly
  what it is not.
- The chips on the grid get the pill the design gives them. They are read
  against whatever the drawing puts behind them, and grey words over a link are
  words nobody can read.
- "2 of 3 required" named an obligation the reader does not have -- it looked
  like two had to be switched on before anything would happen. It says what the
  number reports instead: how much is being asked of a solution.
- The three values on a position row share the space rather than each holding a
  fixed width. Fixed, every one had to be sized for the longest value it might
  ever hold, and they could not all be -- so whichever lost had its unit cut
  off. That was "301 deg" arriving as "301 de".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s beside it

Two things were missing, and they turn out to be one thing.

Insert used to be a one-way door: it added a machine, switched itself off, and
forgot which machine it had added. So the loop the mode exists for -- try a
solution, look at it, change a position, try the next -- was not available. And
the positions vanished the moment the reader left Synthesis, taking with them
the only record of what the linkage on the grid was *for*.

Now the design owns the joints it inserted, by id, and the ownership rides in
the URL with the rest of it. Inserting again revises that machine in place.
Nothing else in the drawing is ever touched, which is what makes this safe in a
project that already holds work: another machine can sit beside it and Insert
will not reach it.

Ownership answers one of four things, and each calls for something different:

  none        nothing of ours is there -- first insert, or Undo stepped back
              past it, or the reader deleted it. Insert simply inserts.
  ours        exactly what we wrote. Insert replaces it without asking; it is
              our own previous answer.
  edited      still ours, still separable, but moved by hand. Insert stops and
              says what would be lost, offering the two things the reader could
              mean: replace it, or keep it and insert a new one beside it.
  entangled   pinned to another machine, or half deleted. We can no longer take
              it back cleanly, so we stop claiming it.

A warning on entering Edit was the other candidate for this, and the
multi-mechanism case is what ruled it out. With a second machine in the
drawing, going to Edit to work on *that* is an ordinary, innocent act -- a
warning that fires on it is both wrong and quickly trained away, and one that
severed the link would cost the reader their synthesis loop for doing something
unrelated. Warning at the moment the work would actually be lost is later but
exact, and it can say what is at stake rather than what might one day be.

Which leaves nothing needing to be said at the boundary, because the positions
say it themselves: they stay on the grid in every mode, faint and out of the
way of every click, with the canvas menu to clear them -- one at a time from
Synthesis, or all of them from anywhere. Clearing them is not an edit to the
mechanism, so unlike everything else on that menu it does not wait for the
start pose.

Two smaller things fell out of it: a right-click while placing was armed
dropped a position, and the analysis modes cleared the whole context menu
rather than only the half of it that edits the drawing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KohmeiK

KohmeiK commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Feedback round. Six direct fixes in d1f05f0, and the design question in ffc6533.

The click bug

Placing a position went through pastDragThreshold, which returns true once the button has been held for 100 ms — a time gate, not just a distance one. That is right for a part already on the grid, where holding still is how you take hold of something, and wrong for aiming at an empty spot. Every click slower than a tenth of a second was silently dropped, which is why placing seemed to need several. Distance is now the only thing that tells the two gestures apart there.

The rest of the small ones

Generate now reports itself for at least a second. The floor is under the progress state, not the work — the search starts at once, and a slower one simply takes longer
Selection no position row is selected until one is asked for
Chips got the pill the design gives them; grey words over a link are words nobody can read
"2 of 3 required" named an obligation the reader does not have. Now "2 of 3 narrowing the search"
"301 de" the three values on a row now share the space instead of each holding a fixed width. Fixed, every one had to be sized for the longest value it might ever hold, and they could not all be

The design question

The proposal was: free movement between Synthesis and Analyze, and a warning on entering Edit that manual edits would sever the link.

The trigger is what I changed, and the multi-mechanism case is why. With a second machine already in the drawing, going to Edit to work on that is an ordinary, innocent act. A warning that fires on it is both wrong and quickly trained away — and one that actually severed the link would cost the reader their synthesis loop for doing something unrelated to synthesis.

So the link breaks when the synthesised linkage is actually changed, not when a mode is entered. Ownership is per-machine, by joint id, and rides in the URL — so it survives a reload and steps back correctly when Undo removes the insert.

Ownership answers one of four things:

state what it means what Insert does
none nothing of ours is there inserts
ours exactly what we wrote replaces it silently — it is our own previous answer
edited still ours, but moved by hand stops and offers Replace it / Keep it, insert a new one
entangled pinned to another machine, or half deleted stops claiming it; the next Insert makes a new one

entangled is the case worth calling out: if you weld a synthesised joint to your own machine, synthesis can no longer take its half back without either leaving a link hanging off nothing or cutting into a machine that was never its to touch. It releases the linkage instead.

Which leaves nothing needing to be said at the mode boundary — because the positions say it themselves. They now stay on the grid in every mode, faint and pointer-events: none so they cannot swallow a click meant for a real part. Right-click clears them: one at a time from Synthesis, all of them from anywhere. Clearing them is not an edit to the mechanism, so unlike everything else on that menu it does not wait for the start pose.

Two bugs fell out of building it: a right-click while placing was armed dropped a position, and the analysis modes cleared the whole context menu rather than only the half that edits the drawing.

Verification

  • 56/56 in e2e/synthesis-redesign.mjs, including a drawing that already holds a hand-drawn four-bar — it is untouched through insert, replace, conflict and undo.
  • 1160/1160 unit tests, run in three batches. The machine has stray ng serve processes from other worktrees pushing load average past 30; at that load the heavier fixtures hit the 5 s per-test timeout, so a single full-suite run reports failures that pass on their own.
  • Production build clean.

🤖 Generated with Claude Code

KohmeiK and others added 8 commits August 20, 2026 19:50
…back to Generate

Eleven notes from a read-through. Three of them turned out to be the same
thing, and one was a rule that made the mode tedious to use.

**The preview looked unlike the drawing because it was drawn by different
code.** Positions, previewed solutions and ghosts were strokes on a line; the
drawing builds a filled outline at a quarter of the object scale, paints it at
0.7 opacity and strokes it in its own colour. The widths matched by arithmetic
and nothing else did. They now share one capsule builder, and the preview's
colours come from the same ColorService `insert` asks -- so it cannot promise
one thing and the drawing deliver another.

**Everything was small because the old panel kept the object scale in step and
the rewrite did not.** It called `updateObjectScale` whenever a pose was
created. Restored for the first position on an *empty* drawing: object scale is
a global, and resizing every bar in a drawing that already holds work because a
position was placed would be a change nobody asked for.

**"Driven from" moved nothing a reader could see.** Both ground pins were drawn
identically, so the control looked broken. The driven pin now wears the motor's
mark, from the same two assets the drawing uses -- which also makes visible the
thing that is otherwise hard to explain: with a driver fitted, neither ground
pin is the input at all.

**A nudge is a different answer, not a different question.** Moving a position
sent the reader back to Generate, which made the button the thing they spent
the session pressing. The search now keeps up with a move on its own -- it is
keyed on the design, and the chosen candidate is identified by where its pins
sit on the link, so it survives. Only a change to what is being *searched for*
-- a position added or removed, a requirement switched -- goes back to Generate.
The search holds still through a drag and catches up on release: the positions
follow the pointer either way, and re-running a full crank revolution per
candidate on every pointermove would stutter for an answer nobody can read
mid-gesture.

**Four corner grips promised two axes of scaling and delivered one.** A
position has a place, a heading and a length, so it now has one handle each:
the body, a knob above it, and a square grip off the front end on the bar's own
axis, which is the direction it actually pulls.

The rest: the design's sections fold away like every other panel's; a lone
candidate is not given a gallery of one to be compared against, nor a letter to
go looking past; the three solution rows had two different label sizes because
`font: inherit` on a button reaches a row that sets none; the transport's
direction arrow was clipped by a 22px button holding a 24px icon box; the
requirement count is gone; and the marks on the track lost their white ring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thing

Insert is the one moment in this mode that changes anything, so what has not
happened yet must not look like what has. The previewed linkage is now drawn
with a broken outline and a lighter fill: still in the colours it will be built
in, so the reader can see what they are choosing, but unmistakably an offer
rather than a part.

The gap under the Positions heading was an empty header row. It carries the Add
position button, and with all three placed there is no button left to put in it
-- so it stood there as a band of air between the heading and the first row. It
is drawn only when it has something in it.

And a heading over a gallery says what there is to choose between; over a
single solution "1 linkage works on one assembly" only repeats, at greater
length, what the section under it already says.

One thing found on the way: decoding a URL replaced the design without telling
the search, so candidates found for the last design could outlive it. A decode
is as structural as a position being removed, and now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The copy scan found the confusion behind the yellow box. The first section was
titled "Coupler" and held a Length field belonging to a different bar: the
coupler is the four-bar's own floating member, pinned *to* the end-effector
link, and only the same length when it happens to be pinned at its ends. That
is exactly what the first requirement decides -- so "letting the pins slide"
and "Coupler is exactly 5.00 cm" were two readings of one word, and the note
that used both read like a contradiction.

One word per concept now, everywhere including the status strip and the grid:

  position          never "pose"
  end-effector link the bar being positioned -- and the section's name
  coupler           only ever the four-bar's own member
  solution          one of the four-bars found; never "candidate"
  linkage           the machine itself
  ground pin        never "ground pivot"
  requirements      never "criteria"

The requirement is named for the choice rather than its consequence -- "Coupler
pinned at the link's ends" -- and the note that explains what to relax now says
so in those words, always offers moving a position as the way out that costs no
requirement, and sits *below* the requirements rather than among them: it is
not one of them, it is what they have jointly done.

Space, in a panel that has little to spare: the Positions buttons move into
that section's own heading; the strictest requirement is offered first; the
gallery keeps three columns when it is opened out rather than re-flowing to
two and making the reader find their place again; a lone solution loses the
letter it had nothing to be told apart from; and the results section is drawn
only when it has something in it, which was the band of air above "Solution".

A driver that cannot be fitted now turns its switch off and says why on hover.
It used to be a paragraph appearing under a switch that had just been flipped,
explaining that what was asked for had not happened.

Clicking empty grid lets go of the selected position, as it does everywhere
else in the app. And the footer's Delete says what it deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report was that a solution claiming all three reached only the first, and
it was two faults wearing one symptom. Both were found by driving the linkage
rather than asking it, which is what the new spec does over four hundred random
designs.

**Reached meant solvable, not driveable to.** `assess` worked out which
positions the loop closes at, and separately how far the crank can actually
turn; it never compared them. The loop can perfectly well close at an angle the
crank cannot reach -- the circles intersect again on a stretch of the curve
reachable only by taking the linkage apart, which is precisely what a branch
defect is. So a position now counts when the loop closes there *and* its crank
angle lies inside one continuous run of travel from where the linkage is drawn.

**And a linkage can reach a position and stall on it.** At a dead point the
transmission angle goes to zero, the coupler pin moves hundreds of units per
degree of crank, and no force turns it. On paper it passes through; in metal it
arrives and stops -- which is what "gets stuck" looks like. The number was
already on the card: the solution in the report read "min angle 11°". Solutions
whose worst transmission angle falls under fifteen degrees are no longer
offered as reaching all three, and where they are shown at all the card says
"stalls at 11°" rather than dressing it as a minimum.

**The six-bar flickered because the preview was driving the wrong linkage.**
The dyad is sized to carry the input across the span the three positions
occupy, not across a whole revolution, so past that span its crank and coupler
no longer reach the pin they drive and the elbow has no solution. The preview,
solving per frame, dropped the driver's two links for those frames. With a
driver fitted the transport now offers only the travel the six-bar can actually
make -- which is also the honest thing to offer.

Three smaller ones. The region's four numbers are plain, with the unit said
once for all of them, because four values each carrying "cm" cannot also show
their digits. Every position now draws the point its coordinates are measured
from, so Back, Center and Front move something visible rather than only making
the numbers jump. And a position is drawn round at the back and tapered to a
point at the front: a capsule is unchanged by being turned half a revolution,
so a design that solves perfectly once a position is flipped was impossible to
see -- the drawing of the wrong one and the right one were the same picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six-bar barely moved, and on the design in the report it moved four
degrees. The preview was running the train backwards: it stepped the four-bar's
own crank and drew a driver onto whatever came out. But once a driver is
fitted, the four-bar's crank is not an input at all -- it is an output, rocking
back and forth as the driver goes round. Stepping it therefore covered half a
stroke, once, and then ran out of angles at which the dyad could close at all,
which is where the four degrees came from and why the links flickered out.

Now it is solved forwards, the way the inserted mechanism is solved and the
reason that one always ran correctly: the driver crank turns, which places the
elbow, which places the pin it drives, which sets the four-bar. On the reported
design the preview goes from four degrees of travel to a full revolution, and
its five links hold together across every sample of it.

Open and Crossed are one solution with a switch on it. They are the same four
bars, the same pins in the same places, closed two different ways -- so listing
them as two entries asked the reader to compare a thing with itself, and used
up half the gallery doing it. One card per construction now, and the letter
belongs to the construction, so flipping the switch does not rename the
solution under the reader's hand.

And the position shape goes back to the capsule every other link on this canvas
wears. Shaping the outline did tell the two ends apart, but it cost more than
it was worth -- the bars stopped looking like the links they are. Which way
round a position is is now said inside it, with a chevron three quarters of the
way along pointing at the front, which is how a drawing normally says it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s not one

Generate and Insert were never both available: one of them is always the step
the reader takes next, and the other is not a choice. Having them as two
buttons in two places meant hunting for whichever was live -- and the Generate
one lived in the scroll area, so it could be scrolled off the screen at the
moment it mattered. They are one button at the foot of the panel now, which
reads Generate solutions until there are solutions and Insert into grid after,
with the search reporting itself directly above it.

It names the search even before there is anything to search, greyed. Reading
"Replace on grid" at two positions placed is true and useless: what is actually
next is the search, and the reader is one position from it.

And a driver that will not fit these positions greys its switch, rather than
only explaining itself to whoever thinks to hover. The reason is still there on
hover; this is the part that says not to bother pressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r full width

Three from a read-through.

The sentence under the progress bar described the search accurately and was
gone before anybody finished reading it, which is a sentence doing no work. The
bar says the same thing and says it in the time available.

A finished search puts its answer at the bottom of a panel the reader is
looking at the top of. Nothing above it changes, and the button they pressed is
in the foot, which does not move -- so there was no cue that anything had
happened down there. The panel now scrolls to meet it, after the frame that
renders it, and without the animation where the reader has asked for less of
that.

And a section header is a target that runs edge to edge. The inset sat on the
header row, so the toggle's own background -- its hover, and its hit area --
stopped short of both sides, leaving a strip beside every heading that looked
like part of it and did nothing. The inset belongs to the text, so it moved to
the button's own padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ferent one

The flat side on that otherwise curved path was a line ruled across a gap.

The trace asked the *four-bar* where it would be at each step, while the range
it was stepping belonged to the *driver's* crank. Two different cranks, so the
angles meant nothing to the solver they were handed to: with a driver fitted,
eighteen of sixty samples did not close at all, and the loop skipped each one
and carried on with a line -- drawing a straight edge across a region the
linkage never visits. It now steps the same solve the preview itself runs, and
where a phase genuinely will not close the pen lifts rather than ruling across.
Sampled at 240 rather than 60, so a stretch the coupler crosses quickly is
drawn as the path it takes rather than one long chord over it.

Checked against the real thing rather than by eye: the previewed path and the
frames the solver produces for the same linkage, once inserted, agree to within
eleven internal units -- four hundredths of a centimetre -- in both directions.

The dividers were two rules leaning together. Each section carried a bottom
border and each section's header a top one, so every boundary in this panel
came out at twice the weight of the ones everywhere else. The header draws it;
the section no longer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KohmeiK
KohmeiK changed the base branch from synthesis-and-circular-links to multi-mechanism-redesign August 21, 2026 18:30
KohmeiK and others added 10 commits August 21, 2026 12:06
…not fail

An outside review found four real bugs and one test of mine that was proving
nothing. Taking them in the order they matter.

**The transmission angle was sampled, and the sampling missed the drops.** It
walked the stroke every two degrees and rounded, so a candidate reported at
sixteen degrees measured four and a half between two samples -- and was offered
as reaching all three positions. It does not need sampling: the angle follows
from the distance between the crank pin and the far ground pin by the cosine
rule, and that distance is extreme only at the ends of the stroke or where the
crank points at or away from the far pin. Four angles to check, exactly, and
compared against the cutoff unrounded.

Fixing that exposed the real fault underneath. The stroke was built from raw
atan2 angles measured against a walked range that can sit a whole turn away
from them, so the interval was often offset -- and sometimes inverted, and
therefore empty, in which case the answer came from two arbitrary endpoints.
The same class of bug as the reachability one, in the same function.

**And my test for it could not have failed.** It asked whether any defect-free
candidate had a transmission angle under the cutoff, which `defectFree` is
defined to make impossible. It now measures the angle independently, from the
bar lengths, and compares -- across three hundred designs, and stepped so both
ends of the interval are always sampled, because the angle collapses at a
travel limit and that is exactly the sample a rounding error drops.

**A driver was offered that could not turn all the way round.** `driverDyadFor`
sizes a crank and coupler to carry the input across the arc the positions need
and stops there; it never asks whether the four-bar closes everywhere in
between, and sometimes it does not. The panel promises one full turn, so
availability now requires one: the same forward walk that feeds the transport
decides whether the switch may be pressed at all, so the two cannot disagree.

**A corrupt number in a URL was absorbed rather than refused.** The validator
counted fields but never checked that a number was made of characters the
encoder emits -- and an unknown character decodes as -1 rather than failing. A
single bad digit in the coupler length brought back all three positions around
a link that was not the one shared. That is the half-load the validator exists
to prevent. It now checks the alphabet, and refuses a design that describes
itself twice.

**A drag released off the canvas never ended.** The canvas hears pointerup only
on itself, so a release over the panel left the gesture live: the canvas could
not be panned and the search stayed frozen until something else was clicked.
The pointer is captured for the duration, with a window-level release behind it
for the pointers that cannot be captured. Gestures are also now left-button
only -- a right press is asking for the menu, and taking the pointer took the
menu with it.

Two smaller ones. The panel said pins could be pinned "anywhere ... at any
length" and offered to "search for any four-bar", while the search tries nine
placements and shows at most eight constructions; it now says what it does, and
a heading that counts more solutions than can be opened says how many are
shown. And the rewritten rows had lost their keyboard and their names: a
position row is a button again, the delete is a button, and every field carries
its own label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Driven from Pin A". There is no Pin A on the screen. Nor a B, C or D, so
"Ground A–D" and "Coupler B–C" in the link lengths named bars the reader had no
way to identify either. The preview draws its pins' letters now, whatever the
show-ids setting says, because the panel is talking about them either way.

Looking for more of the same turned up three others.

The link lengths were half one thing and half another: two rows named their
pins and two did not. All four do now -- crank A–B, coupler B–C, rocker C–D,
ground A–D -- and the driver's two say which pins they run between as well.

"Coupler pinned: at both ends cm". The unit was appended to a phrase rather
than sitting beside a number, so where there was no number it read as that, and
where there was one it read "3.0 past the back cm". The unit goes with its
number.

The transport said "full crank rotation" beside a six-bar, where the crank
being turned is the driver's and the four-bar's is an output. It names which.

And Space did not press a focused button -- anywhere in the app, not just here.
The shortcut service skips a keystroke aimed at a text field, but a button is
not a text field, so Space reached the global handler and was preventDefault-ed
on its way to the button it was aimed at. Every button in the app was reachable
by keyboard, focusable, outlined, and inert when pressed; nothing noticed for as
long as nobody tried to drive it without a mouse. Space and Enter now belong to
whatever natively answers them, and every other shortcut still works with a
button focused.

Also from the second review: the stroke a solution is judged over is the
shortest arc between its three positions, found by looking for the widest gap
between them. Taken as the smallest and largest of the three angles it named
the long way round the circle on a full-turn crank -- three hundred and thirty
degrees where the real arc was eighty -- so linkages were being judged on travel
they never make between the positions, which rejected good candidates and
accepted binding ones. The test for it checks the stroke that was chosen, not
only the angle computed over it, and fails on the old behaviour.

The position row is a plain container again: it had been given role="button" so
it could be reached by keyboard, which made it a button wrapping two textboxes
and another button, and its Space handler swallowed the nested button's own
activation. The numbered chip is the control instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ind home

Three from using it.

The ghost was drawn small and the position it turned into was drawn large, so
the click appeared to grow it. Object scale decides how big parts are drawn and
it was being fitted on the first click -- after the ghost had already been
drawn at the old one. It is fitted when placing is armed instead, which is the
moment before the ghost first appears. Still only on a drawing with nothing in
it: the scale is global, and resizing someone's work because a position is
about to be placed is a change nobody asked for.

Driving from Pin D crawled. Reading a solution from its far pin re-assesses the
swapped linkage, which walks a whole revolution a degree at a time -- seven
hundred solves -- and that was done on every call. Drawing the coupler's path
asks for it once per sample, two hundred and forty times, on every animation
frame: something like a hundred and seventy thousand solves a frame. Pin A
never noticed because that path hands the candidate back untouched. It is
remembered now, and the two pins draw at the same speed.

And committing from halfway round the cycle replaced the linkage on screen with
a differently-posed one between two frames. What gets built is always the start
pose, so the preview winds back to it first, over the same 220ms the app eases
everything else home at -- and not at all where the reader has asked for less
animation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three from the third review, all of them mine and all from the commit before
this one.

Insert was re-entrant. Each press started its own wind-back, so a double-press
committed twice -- rebuilding the linkage and writing two history entries for
one intention -- and a press followed by leaving the tab committed afterwards,
onto a drawing the reader had moved on from. One wind-back at a time now, its
own frame handle rather than sharing the playback loop's, and both cancelled
when the panel goes.

Fitting the object scale was put in the button's handler, which missed the
other way in: clicking an empty position row also arms placing, and that route
drew its ghost at the old scale. Both go through one method.

And the wind-back interpolated raw angles, so near the end of a full turn it
took the three-hundred-and-fifty-nine-degree route to somewhere one degree
away. It takes the shorter direction, as the app's own easeToStart does.

Measured: three rapid presses commit once; a press then a tab change commits
not at all; both ways of arming fit the same scale; and winding home from 355
degrees past travels four degrees rather than 355.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fourth review found that a single Insert wrote two. Inserting rebuilds the
mechanism through `updateMechanism(true)`, and that `true` is a save -- then the
panel recorded again on the way out. One Undo stepped back over the second of
the two and left the linkage sitting on the grid, which is the one thing Undo
must not do.

My own test could not have seen it. It counted calls to `solution.insert` and
then took the linkage away by calling `undoInsert` directly, so it never went
near the history it was supposed to be about. It presses Undo now, and requires
the grid to be empty afterwards.

Two more from the same review. The replace warning waits to be answered, by
design -- but its answers act on this panel, and it was still on screen, and
still clickable, after the reader had left for Edit. It goes when the panel
goes, and its actions check anyway. And the wind-back's normalisation was
[-180, 180] where the comment said (-180, 180]: at exactly half a turn both
ways round are the same length, so it now goes forwards, which is the way the
crank was already turning.

The arming check I added last time pressed the button and then asserted about
the row -- proving the route that worked and not the one that did not. It
clicks the row, before the suite arms from the button, and hands back the state
it borrowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings from the fifth review, and the falsification of my own answer to
one of them.

I had checked the scale-fitting guard myself and reported it sound: it fits
only when `links.length === 0`. A joint on its own belongs to no link, so a
drawing holding nothing but loose joints read as empty and arming resized them
under the reader -- 140 to 360.9, measured. Emptiness now means joints too.

Choosing "Driven from Pin D" renamed the linkage. Reading it from the far pin
puts pin D in the field called A, and everything that drew a letter or named a
bar took the field's name -- so the motor appeared beside a pin marked A, the
crank became A-B, and the one control whose entire job is to say which pin
drives was the control that made the letters stop meaning anything. Letters now
follow the pins.

Insert defers 220ms to wind the preview home and worked out what to build only
on arrival, so choosing another card inside that window built that one instead,
from a press aimed at the card before it. The press now remembers what it was
aimed at and cancels if that changes -- a change of mind, not a redirection.

After inserting, the preview was suppressed for any owned linkage rather than
for the one being looked at, so choosing a second solution left the first
standing solid while the panel offered to replace it: the replacement went in
having never been shown.

And the far-pin cache held one entry, which hovering a card and drawing the
picked one evicted in turn -- the seven hundred solves it exists to avoid, just
less often.

Four tests asserted less than their names. The gallery's "three columns" never
opened the gallery and passed on a flex row having no columns at all. The Space
check dispatched a synthetic event and asked only whether anything prevented
it, which proves nothing about the native activation that was being suppressed;
it presses a real key on a real button now. `defectFree` was stated as
`onBranchCount === 3`, half the rule, and true of that corpus only because
nothing in it binds -- there is a binding corpus now, and dropping `!binds`
fails it. And three of the six named designs offer nothing to walk, which the
per-design walk passed on silently; the corpus is tallied instead.

Each of the four fixes was reverted in turn and the check that covers it fails.
The first attempt at that found nothing, because deleting a guard left a local
unused, the compile failed, and the dev server went on serving the previous
bundle -- a green run against code that was never loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Naming the preview honestly was half a fix. Insert assigned letters in field
order, and reading a candidate from the far pin puts pin D in the field called
A -- so the pin the panel had just labelled D arrived on the grid called a,
under a dimensions list reading "Crank D-C" over a crank whose ends were marked
a and b. The mismatch had moved rather than gone.

The letters now follow the pins through insert as well, and links are named by
their ends in alphabetical order rather than by relying on the letters having
been handed out in that order to begin with.

Reverting it fails the new check with the whole story in the detail: the motor
drawn at (-394, -3997) and the joint built there called A.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anges

Four from the sixth review.

A design that put four joints on the grid and has since lost one has been cut
into, and Insert leaves that alone rather than replacing it. The decoder
dropped the ids of joints that were no longer there -- rightly, since a claim
on a missing joint would be inherited by whatever new joint next took that
letter -- but dropping them silently threw away the only evidence that
anything had been lost. A shortened list looks exactly like a smaller linkage,
so a reload turned "you cut into this, I will work around it" into "this is
mine, I will delete it", and the reader's moved joint went without the warning
that exists for precisely that. The fact now rides in the URL on its own, in a
flag slot that was already being written as false, so nothing previously shared
decodes differently.

`needsReinsert` compared the four-bar's four pins and nothing else, so fitting
a driver to an inserted four-bar -- or taking one off an inserted six-bar --
left the panel saying "Inserted into grid" over a drawing that no longer held
what was being looked at, with the preview hidden because it agreed. It counts
the joints and compares the driver's two as well.

Leaving Synthesis for Edit saved the mechanism a second time, identically,
because the old mode built onto the grid as the reader typed and something had
to write that down. Nothing has set the flag that was supposed to gate it for
as long as the redesign has existed; the redesign only touches the drawing
through Insert, Undo-insert and Delete, each of which saves for itself. The
first Undo after inserting therefore appeared to do nothing. Both the save and
the dead flag are gone.

And the letters. Yesterday's fix made the preview name the pins truthfully but
still named them A-D, while insert took the next letters free -- so beside a
single loose joint the preview promised D/C/B/A over pins that arrived as
E/D/C/B. Preview and insert now ask one function for them, which is also what
the "Driven from" control names its two ends after.

Three more tests that asserted less than their names: the unit check passed if
the units vanished entirely, the off-canvas drag check passed when there was
nothing to drag and never established that a drag had begun, and the corpus
tally required only one of the three designs that have answers to have one.

Each of the four fixes reverted in turn fails its own check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
If every joint the design wrote has since been taken away it owns nothing, and
there is nothing left to be entangled with -- but the flag was set on any
shortfall, including that one, and a design carrying it writes a section into
every URL it produces from then on. `ownership` already answered "none" in that
case, so the flag was saying something no one asked and no one could act on.

Verified separately that both of the reported designs still re-encode to the
exact bytes they were opened with, so the flag slot has not shifted anything
beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two from the seventh review, both of them my own fixes landing short.

`previewLetters` counted every joint on the grid, including the linkage insert
was about to take away. So a replacement promised E through J and then built A
through F, and the dimensions list renamed itself off the linkage the instant
that linkage was inserted -- "Crank E-F" over a crank standing on the grid as
A-B. It now counts the grid as insert will find it: a replaceable linkage of
ours releases its ids, and survivors of one that has been cut into do not,
because those stay. `determineNextLetter` grew the parameter for it, and the
six ids are worked out once per change of the grid rather than three times a
frame.

And being cut into now sticks from the moment it is noticed, not only across a
reload. Ids are handed out again after a deletion: delete one of ours, draw a
joint, and that joint takes the letter we just lost -- so the count came back
up, every id was present, and the linkage read as wholly ours with somebody
else's joint standing in it, to be removed without warning by the next replace.
Reverting the latch drops it to `edited`, which warns and then deletes anyway.

Four more tests. The greyed-driver check compared two things that were both
false -- these designs raise no refusal, so it established nothing; it now
sweeps every candidate and both drive ends and requires having seen the switch
live, with the refusal itself covered where it can be built to order in
driver-dyad.spec.ts. The full-turn check ignored a driver that was offered and
never sized. And two `every()` calls were reported as agreement over empty
lists.

The replacement case is what let this through at 101/101, so it is a check now:
reverting either fix fails the check written for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KohmeiK and others added 3 commits August 21, 2026 15:46
No product change: the eighth review found the code sound and these three
tests unable to fail.

The greyed-driver check had never met a design that refuses a driver, so it
compared two false values and would have passed with the binding deleted.
Refusals turn out to be rare but reachable — three positions needing more than
half a turn at the input — so there is a design here that produces one, and the
switch is now read on both sides of it.

Finding that design cost two false alarms worth recording. The check first
"failed" because it read the DOM in the same turn as the state change, before
Angular had drawn it. Then the falsification "passed" because the template
holds two `[disabled]="!!driverRefusal"` bindings — the text button and the
switch — and I removed the first one, leaving the switch bound as it always
was.

The dashed-preview check asked whether every rendered bar was dashed, which an
empty canvas answers yes to; it requires the bars to be there.

And the reused-id check stopped at "still entangled" without ever performing
the replace it was about. Adding the replace was not enough either: it looked
for the reader's joint by name, and removing that joint frees its letter for
the very next insert to hand straight back out, so a joint called D exists
either way — somewhere else, belonging to somebody else. It is found by where
it is now.

Reverting each of the three fails the check written for it: the class off the
bars, the binding off the switch, and insert taking an entangled linkage away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ninth review found the defect the last four passes had been circling.

Where insert put each joint was held in memory and nowhere else. Open a shared
link, or a state restored by Undo, and that record was empty -- and an empty
record meant "nothing has been touched", which is the one condition under which
Replace helps itself. So: insert a linkage, drag a joint somewhere you want it,
share the link, open it, press Replace, and the joint goes back where synthesis
had wanted it, with no warning, because the warning is for linkages that have
been edited and this one had forgotten that it was. The same blindness made a
deleted-then-reused id read as ours to delete, and made Undo after a
replacement report the linkage it had just restored as edited, because the
record still described the linkage that had replaced it.

The baseline is written down now, in an entry of its own beside the ids. Its
own entry rather than beside them, because an id is a letter and a number is
base-N over an alphabet that includes letters, so one entry holding both cannot
be read back without guessing.

And the default when there is no baseline is now to ask rather than to assume.
Every insert writes one, so the only way to be without it is a URL from before
this existed -- and asking costs a click, where assuming cost the reader their
work.

Four tests that did not establish their names. The duplicate-entry test doubled
an entry without fixing the length, so the checksum rejected it first and the
test passed on an error that would still be raised with the duplicate rule
deleted. The naming test asked whether the first candidate was called A, which
every candidate being called A satisfies. The Open/Crossed check proved the two
assemblies were not two cards but never touched the switch that replaced the
second one. And the greyed-driver sweep is renamed to what it proves: these
designs never refuse a driver, so it establishes agreement, not greying.

The reader's linkage untouched, and the reader's linkage moved, are both
checked across a real shared link -- both are needed, because without a
baseline the safe answer to "was this moved" is "ask", which an unmoved
linkage would get too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, on the line two branches happened to add an import to. The base
had already taken the intro.js tour out, so the import this branch still
carried had nothing left to use it; the incoming `SvgArrowComponent` is in the
component's own imports list and stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KohmeiK
KohmeiK merged commit 8768a4d into multi-mechanism-redesign Aug 22, 2026
5 checks passed
@KohmeiK
KohmeiK deleted the synthesis-redesign branch August 22, 2026 00:09
KohmeiK added a commit that referenced this pull request Aug 22, 2026
…omises

Seven findings from a GPT-5.6 review, four of them correctness.

The force-graph row counted `joint.links` plus ground and called that "how many
parts meet here". A floating slider's carrier is deliberately not in `links` --
the slider rides the bar rather than being one of its members -- so every
Whitworth, Scotch yoke and shaper quick-return was told "one part meets it"
while the force solver was generating a reaction against two. This is exactly
the wrong-reason failure the redesign exists to stop, and it was mine: I wrote a
count instead of asking the model, and said so in the comment at the time. It
asks `jointHasForceToGraph` now, which reads the reaction index the panel reads.

Two deletion rows promised less than the deletion does. On a welded compound
`linksRemovedByDeleting` asked only the compound's own joint count, so a
four-joint compound looked safe while the two-joint leaf inside it was doomed:
it is asked leaf by leaf now, which is what the deletion actually does to it. A
cylinder joint hard-coded "Delete Joint and Cylinder" and swallowed any
neighbouring bar the mount was also holding; that bar is named.

"Locked means undeletable" was a claim the menu made and nothing enforced. The
Delete key and the panel button reach the same joint, and both deleted locked
parts happily -- so a greyed row sat beside a live keystroke doing the opposite.
The rule is `deleteRefusal` on the service now, and the menu quotes it. Clearing
the whole drawing is the one caller that passes it by, because that is a
different act from deleting a part.

And the card was a snapshot with live keys behind it: pressing K with a joint's
menu open locked the joint and left the menu showing the unlocked state, Delete
still enabled, and clicking it deleted the joint that had just been locked. Any
shortcut closes the card now.

Duplicate Link copied a shape rather than a body -- a bar carrying seven grams
and a hand-set moment of inertia came back with neither, and a force analysis
that no longer agreed with the original. Mass, inertia, fill, disc and the CoM
anchor come with it; the name and the lock do not.

The review also found the e2e suite had not been swept: eleven suites still
drove `cMenuItems` or `#contextMenu #menu-item` and threw on the first menu
they opened, which reads as a broken app rather than a stale test. They are
migrated, and two of them caught things worth catching. `phase4-stack-and-menu`
holds §4.1's rule that a slider is *offered* the weld and refused with the
reason -- I had hidden that row on the design's say-so, and the model does not
agree, so it is back. And the sweep found the builder throwing outright on a
SliderBlock handed to it as a link: not reachable by pointer, but a builder that
throws on a shape it was given is worse than one that says little.

Left alone: the synthesis section of `circular-link-and-driver` still fails,
because #243 renamed the panel's model and reshaped the flow it drives. The
field rename is fixed here; the rest is that suite's own stale test of a
feature that changed shape, and it fails the same way on the base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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