Skip to content

Phase 4: joint graphics, the panel, and two gestures (Gate 4) - #229

Open
KohmeiK wants to merge 27 commits into
stagingfrom
phase-4-joint-graphics
Open

Phase 4: joint graphics, the panel, and two gestures (Gate 4)#229
KohmeiK wants to merge 27 commits into
stagingfrom
phase-4-joint-graphics

Conversation

@KohmeiK

@KohmeiK KohmeiK commented Aug 1, 2026

Copy link
Copy Markdown
Member

Phase 4 of docs/joint-types-plan.md: the joint types built in Phases 2 and 3 become visible and editable. Implements the design handoff — mark sheet, motion specs, gesture prototype, panel — against the grammar in §2.8.

Gate 4 is met. Every cell of the 2×2 is reachable from every other in ≤2 clicks and each control moves exactly one axis (joint-type-2x2.spec.ts, all sixteen transitions); every state the marks distinguish round-trips through the URL (mark-states-round-trip.spec.ts, fourteen states).

What's here

The mark system (§2.8) replaces the SliderPill + SliderTrackMid images with drawn geometry in the layer order the spec makes normative: hatch, carrier + channel, black block, rider + weld plate, arrows, markers. Every dimension is a multiple of R, the joint's own radius — never a pixel, because the canvas pans and zooms.

Two places where the delivered design improved on the brief, both kept:

  • The block is #000 in all four slider cells. A Slide's apparent colour is a visual-only plate in the rider's own paint at the link's own alpha, drawn over the black. That removes colour derivation from the system entirely, so "nothing depends on a link's random colour" holds by construction rather than by discipline.
  • Hatch is the bottom layer, so nothing is ever cut from it — which retires §2.8's "the break in a grounded rail must track the rod" outright. The geometry that needed per-frame maths simply doesn't exist.

A slider can now dangle. §2.4a held that a slot is grounded-xor-floating. The Slider toggle can be turned on for a joint with no carrier, and a carrier is geometry rather than a boolean — no toggle can invent one. So it keeps its block, loses its direction, is drawn in the same red the orphan-joint mark already uses, and the mechanism is invalid until a drag gives it a carrier. reconcileSlots produces it too, reversing a Phase 2 decision that silently re-grounded a stranded slot at its last angle.

Two gestures. Dropping a joint on a bar cuts a slot there, on the pair whose segment the drop is nearest, previewed through the same path subtraction a committed slot uses — so the hover is pixel-identical to the result, and its legibility can't depend on a colour it can't predict. Dragging the block is clamped to its channel: one drag, one quantity.

The panel gets Slider and Weld as independent toggles; Ground stops being disabled by Slider; the angle field appears only for a grounded guide; the carrier is named read-only; turning Slider off stashes the slot so turning it back on restores it.

§2.7's cylinder skin ships here rather than in Phase 5 — it is a rendering question. Revealing turns out to be nothing more than declining to collapse, which is what makes it genuinely additive. Its fixture is deliberately an invalid mechanism: a Slide on a moving carrier is still outside the solver's scope until 5.1.

Bugs this found

Six, five of them only reachable with a real mouse:

toggleGround tested the selected joint for PrisJoint, but the panel selects a slider by its pin every panel click grounded the pin while the guide stayed floating
The panel's Ground control wrote selectedJoint.ground directly no reconcile, no undo entry, and it read the pin's flag — showing every grounded guide as ungrounded
cutSlotOn saved, and so did the drag's release one drag cost two presses of undo
The glyph cache keyed on positions but not on a grounded guide's angle editing the angle moved no coordinate, so the panel reported the new one and the canvas kept the old
A refused weld left the switch on a control showing a state the mechanism is not in
The weld plate's rider anchored on the frame's normal, not the rider's own a wedge tapering from block to far joint — a plausible link shape, and a different one

An SVG mask large enough to cover any pan or zoom also made the browser rasterize a surface that size and downsample the whole canvas into a blur; the channel is a subpath on the carrier's own even-odd path instead, which gets the carrier-coloured edge for free.

Verification

  • 539 unit tests (was 536 on staging +3), production build clean
  • e2e/phase4-marks.mjs — 20 checks, the mark system on all four reference mechanisms
  • e2e/phase4-gestures.mjs — 22 checks driven entirely by pointer events on coordinates read out of the DOM: the drop gesture and its preview, undo integrity, every panel toggle, the dangling repair drag, block clamping
  • Screenshots inspected rather than exit codes trusted; reviewed by GPT-5.6 sol
  • Six mutation checks, each confirming the named test fails when the behaviour it guards is broken

Deferred, with reasons

Make Input refusing at 3+ incident bodies and a redundant-weld warning were both in the design handoff. Neither is a UI question: the first restricts something that works today and could break shared URLs, the second needs a redundancy detector that doesn't exist. Both want their own spec.

🤖 Generated with Claude Code

KohmeiK added 10 commits July 31, 2026 23:29
Five primitives as pure functions, every dimension a multiple of R (the
joint's own radius) rather than a pixel — a px offset grows with the canvas
transform, which has already shipped one animation bug here.

The design package authors its marks at R = 10, so the delivered SVGs are a
numeric reference rather than a picture: the spec reproduces the weld plate's
fillet coordinates exactly, which is what pins the one piece of genuinely
angle-dependent geometry in the set.

weldPlateFillets returns fewer than two paths where there is no concave corner
on screen -- a rider pointing along its own slot is wider than the block is
across, so it swallows the block instead of meeting it at an angle.
Replaces the SliderPill and SliderTrackMid images with drawn geometry, in the
layer order §2.8 makes part of the spec: hatch, carrier + channel, black block,
rider + weld plate, arrows, markers.

The channel is a subpath appended to its carrier's own path data rather than a
mask. Two reasons, one of them found the hard way: the links already fill
even-odd, so appending subtracts the window and the carrier's existing stroke
traces the new edge in the carrier's own colour for free -- which is exactly
what §2.8 rule 7 asks for, with no second element and no colour lookup. And a
mask large enough to cover any pan or zoom makes the browser rasterize a
surface that size, which downsampled the entire canvas into a blur.

The welded marker moves to the mark system's 1.47R. It had been a hand-written
2.2R path predating the system -- larger than the free circle it stands
opposite, which inverted the reading that a weld removes a freedom.

Marks are emitted in the slot's own frame and placed with one transform, so no
path holds a rotated coordinate and the CSS transitions to come compose against
the slot rather than the world. Recomputed only when a fingerprint of what they
depend on changes, since playback asks for them every frame across ~360 steps.
Additive, never a ninth mark: straight arrows for a driven slider, a curved one
for a driven pin. A driven floating pin has no block to put a white arrow on, so
it brings its own dark backing rather than switching to a colour that would then
depend on whichever random colour sits behind it. A driven grounded pin keeps
today's black arrow, which already reads on the white canvas.
Phase 2 held that a slot is either grounded or floating, never neither, and
re-grounded one that lost its carrier at whatever angle it last pointed. That
kept the slider the user drew and quietly invented the one thing about it
nobody had chosen: where it points.

Phase 4 needs a third state, because the panel's Slider and Ground toggles are
now independent and a carrier is geometry rather than a boolean -- no toggle can
produce one. So the slider keeps its block, loses its direction, and the canvas
draws it in the same red the orphan-joint mark already uses. The mechanism is
invalid until a carrier arrives or the user grounds it, and Mechanism refuses
before solving rather than reading a slot line that is not there.

detach() stashes the direction on the way out. A floating slot's angle lives in
its two joints, so letting them go without reading them first loses it -- found
by the test asserting the round trip, which is the whole reason the stash exists.

toggleGround stops dismantling the slider it un-grounds. That branch made
"Ground off" a disguised "Slider off", and it is what stranded a Slide's weld
flag with no block behind it.

Six existing tests asserted the behaviour this reverses; they now asserted the
new contract, including the invariant they were originally written to guard --
that acting on one slider must not disturb another.
Weld replaces the Weld/Unweld button pair. Welding is one axis of the 2x2
(§2.1), and two buttons cannot show which side of it a joint is currently on --
so unwelding a Slide now visibly gives a Slot rather than reading as a separate
destructive action. Make Input stays a button, being a mechanism-wide action
rather than a property of this joint.

Ground stops being disabled while Slider is on. The two were coupled because
toggleSlider only ever produced a grounded slider and toggleGround dismantled
one; with both of those gone, coupling the controls would make a reachable cell
of the 2x2 unreachable, which is the gate this phase has to meet.

A new slider is born dangling rather than grounded, and the angle field appears
only for a grounded guide -- a floating slot's direction is the line through two
of its carrier's joints, so there is no number to type and no frame to type it
in. The panel says what the slot is cut into, and says so read-only.

Turning Slider off stashes the slot on its pin, by id rather than by reference,
so turning it back on restores the guide the user had; a carrier deleted in the
meantime simply does not resolve and the slider dangles, which is the same
answer reconcileSlots gives rather than a second policy.

The gate itself is now a test: all sixteen transitions of the 2x2, asserting
both that every cell is reachable in at most two clicks and that each control
moves exactly one axis. Restoring Phase 3's prismatic exclusion in canBeWelded
makes it fail, which is what tells you it is measuring the gate.
Drop a joint on a bar and it becomes a slider riding a slot cut into that bar.
The pair is the one whose segment the drop is nearest -- a link with n joints
offers up to n(n-1)/2 of them -- and the segment is used rather than the
infinite line, so a point past the end of a bar is not between those joints.

The preview IS the result: the previewed channel goes through the same path
subtraction a committed slot uses, so the hover state is pixel-identical and its
legibility cannot depend on the carrier's random colour. Every stand-in
considered fell below contrast on part of the palette, because the palette is
random. The dragged joint is pulled onto the slot line while previewing, exactly
as joint-snap captures, and joint snap still wins when both are in range.

Three defects that only a real mouse could find, all in this commit:

- toggleGround tested the *selected* joint for PrisJoint, but the panel selects
  a slider by its pin -- so every panel click went down the plain-joint branch
  and grounded the pin while the guide stayed floating.
- The panel's Ground control wrote selectedJoint.ground directly, bypassing
  toggleGround entirely: no reconcile, no undo entry, and it read the pin's
  ground flag, which shows every grounded guide as ungrounded.
- cutSlotOn saved, and so did the drag's own release, so one drag cost two
  presses of undo. It now rebuilds without saving, the contract mergeJoints
  already follows.

e2e/phase4-gestures.mjs drives all of it with pointer events on coordinates read
out of the DOM, because none of the three is visible to a URL-seeded fixture.
One drag, one quantity. The drag is projected onto the slot line and clamped to
the span the channel occupies, so the block cannot be pulled out of a hole it is
inside of, and moving it changes s0 and nothing else.

Only for a floating slot. A grounded guide's line is fixed in the world rather
than cut into a body, so dragging its joint repositions the whole guide;
constraining that would leave no way to move a guide at all.

The block is also a drag handle now. It is a far bigger target than the marker
at its centre, and the two sit on the same point, so grabbing the block starts
the same gesture with a better hitbox -- while the plate and the arrows above it
stay transparent to the pointer, since neither is a thing to grab.
A piston is not a new joint type -- it is a Slide whose rod and barrel line up,
drawn as the part an engineer would recognise instead of as a block in a
channel. The test is therefore the shape, not a flag: a floating Slide whose
barrel and rod each carry one other joint, on opposite sides of the block, all
on the slot line. Everything else keeps the ordinary drawing.

Revealing turns out to be nothing more than declining to collapse. The slotted
form was already correct, so selecting any member simply stops the skin standing
in for it -- which is what makes the reveal additive rather than a second
drawing laid over the first. Playback suppresses the reveal and keeps the skin
on, because aiming at the block and reading its travel are the two things the
reveal is for and neither is possible while it is moving.

The auto/cylinder/slotted override lives in the panel only, and only where the
assembly actually qualifies for a skin. It is held on the render service rather
than on the joint: a view preference should not serialize into the URL or enter
the undo stack, so a shared link always opens on Auto and nobody can undo their
way into a different picture of the same mechanism.

The fixture is deliberately an invalid mechanism. A Slide on a moving carrier is
out of Phase 3's solver scope, and the skin is a rendering question -- so it is
published in the gallery for what it draws, not for what it solves.
Toggling Slider or Weld now visibly changes exactly one mark. The transitions
are CSS animations on insert rather than transitions on a property, because the
marks come and go with the structure rather than changing value -- so the
per-frame redraw path stays stateless, which is what keeps the glyphs cheap
across ~360 timesteps. Every displacement is a scale or an opacity against the
element's own box; a px offset grows with the canvas transform, which has
already shipped one animation bug here. prefers-reduced-motion drops all of it,
and the snackbar rather than the shake is what carries a refusal's reason.

The plate animates to opacity 1, not 0.7: it carries its 0.7 as fill-opacity
already, and doing both would land it at 0.49 -- a paler plate than the rider it
is supposed to be the same body as.

Two state-sync defects, both found by adversarial probing rather than by the
suite:

- The glyph cache keyed on joint positions and slot bindings but not on a
  grounded guide's angle. Editing the angle field moved no coordinate, so
  nothing invalidated: the panel reported the new angle and the canvas kept
  drawing the old one.
- A weld the model refuses -- a grounded joint, a driven one, a joint with
  nothing to fuse -- left the switch sitting on while the joint stayed a pin. A
  control showing a state the mechanism is not in is worse than one that does
  nothing, so the toggle now re-reads the model and corrects itself.
Gate 4's third condition as a test: fourteen states -- the eight base marks,
the two dangling ones, and driven sampled across four -- encoded, decoded, and
compared on what the mark system actually reads rather than on the bytes. Two
encodings of the same mechanism are both fine; a round trip that changes the
picture is not.

The discriminating case has its own assertion, because it is the one the format
nearly loses: grounded, floating and dangling all encode as "a prismatic joint",
and what separates them is the ground flag plus whether the three slot tokens
are written. Dropping the tokens makes that test fail and leaves the shape-only
assertions passing, which is what tells you it is measuring the right thing.

The plan now records the two settled departures from what Phase 4 was written
as: eight marks plus an additive overlay rather than twelve glyphs, and the
third PrisJoint state -- along with what was deliberately deferred and why.
@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for pmksprod ready!

Name Link
🔨 Latest commit 26bbc26
🔍 Latest deploy log https://app.netlify.com/projects/pmksprod/deploys/6a7189e9ccf1b90008c48907
😎 Deploy Preview https://deploy-preview-229--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 added 3 commits August 1, 2026 00:42
Every other check starts from a URL, which means starting from a state the app
never actually reached through its own UI. This one starts from nothing: right
click, Add Link, place a joint from tracked pointer movement, grow a second link
off the first with Attach Link, then drag its far joint onto the first bar to
cut a slot, and weld it from the panel.

Two things it had to learn that no seeded fixture would have surfaced: the
welcome tour covers the canvas on a first visit and silently swallows the right
click that opens the creation menu, and the grid's menu and a joint's menu name
the same gesture differently -- Add Link from nothing, Attach Link from
something.
Found by GPT-5.6 sol driving the app with a mouse: Slider off, Undo, Redo,
Slider on gave a dangling slider rather than the slot that was there.

The stash lived on the RevJoint. Undo is a stack of URL strings, so every undo
rebuilds the mechanism from scratch and the joints that come back are new
objects -- an undo and a redo that visibly changed nothing destroyed the thing
the panel had promised to remember. It is keyed by joint id on the service now.

Ids are reused after a deletion, so deleteJoint clears the entry: a stale stash
inheriting a letter would hand a new joint someone else's slot. Still not
serialized -- a convenience within one editing session, not state a shared URL
should carry.

Keying it back on the object reproduces the reported symptom exactly and fails
only the new check, which is what says the check is measuring this rather than
restore in general.
Third of the same family, after the grounded slot angle and the refused weld:
the glyph cache keyed on positions and slot bindings, and a Slide's plate is
painted in its rider's own colour -- which is the whole mechanism by which it
reads as the same body. Changing a link's colour moved no coordinate, so nothing
invalidated and the plate kept showing a colour the link no longer had.

Dropping the paint back out of the fingerprint reproduces it exactly, which is
what says the new check is measuring this rather than repainting in general.
@KohmeiK

KohmeiK commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Marks on the deploy preview, for anyone who would rather look than read:

  • Scotch yoke — a grounded Slide driving from a floating Slot — channel cut down the yoke, indigo weld plate over the black block, hatched rails
  • Inverted slider-crank — a floating Slot — the clearest look at a channel cut through a moving bar
  • Four-bar with a slotted coupler — a slot on a coupler that both rotates and translates
  • Elliptical trammel — two grounded guides at once
  • Hydraulic cylinder — the cylinder skin (§2.7). Click the rod to reveal the slotted form underneath, and try the Auto / Cylinder / Slotted control in the panel. Deliberately an invalid mechanism — a Slide on a moving carrier is Phase 5 — so it is here for what it draws

Worth trying by hand on any of them: drag a joint onto a bar to cut a slot (the channel opens under the cursor before you release), drag the block along it, then use Slider / Weld / Ground in the panel to walk the 2×2. Turning Ground off on a slider leaves it red and dangling with nowhere to slide — dragging it onto a bar is the fix, and the panel says so.

KohmeiK added 3 commits August 1, 2026 00:54
The one dimension in the mark system that is not a multiple of R, and it should
not be: every stroke in the app goes through scaleWithZoom, which keeps a line a
constant width on screen. A hairline that scaled with the geometry would vanish
when zoomed out and become a slab when zoomed in. The R rule governs how big
things are; how thick you draw their edges is a question the app already
answered, and answering it differently here would make the new marks the odd
ones out.
The recolour check drives Angular's debug globals, which exist only in a
development build, so it took the whole suite down when pointed at a deploy
preview. It skips there instead: the check is about cache invalidation and a
production bundle cannot be asked, but everything else about the marks can be --
and is, now, on the artifact that actually ships.

21/21 marks and 25/25 gestures pass against deploy-preview-229 as well as
against the dev server.
1. An unparseable slider angle recursed until the stack ran out. Two causes,
   both older than this branch: the angle was read off the pin -- which has none
   -- so the field was restored to the string "NaN", and the restore emitted, so
   the handler ran again on its own unparseable output. The angle field is a
   Phase 4 surface now, so both are fixed here.

2. Per-joint memory leaked between projects. The slot stashes and the
   cylinder-skin preferences are keyed by joint letter, which is unique within a
   mechanism and says nothing across two: opening a different project in place
   handed its joint B whatever the last project's joint B remembered. A fresh
   load now forgets them, and undo says so explicitly rather than being told
   apart by a view flag that happened to correlate.

3. The mark system carried railStroke, tickStroke, arrowStroke and channelStroke
   constants that nothing used, implying an R rule the drawing does not follow.
   Strokes go through scaleWithZoom like every other stroke in the app, and that
   is the right answer -- so the constants are gone rather than the behaviour.

4. A dangling block was drawn pale blue with a red outline, which broke "the
   block is #000 in every slider cell" for the sake of matching the orphan-joint
   mark exactly. It is black with a red highlight now: the rule holds and it
   reads better. The marks test only asserted black on non-dangling fixtures,
   which is why it missed this; the gesture test asserts it directly.

5. The production marks run reported the recolour check as passing while
   skipping it. A check that reports success without executing turns a
   regression green, so it records as skipped now.
@KohmeiK

KohmeiK commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Adversarial review round (GPT-5.6 sol)

Reviewed the branch with real-mouse interaction testing, not just code reading. It found seven things; all are now fixed on the branch, each with a regression check and a mutation confirming the check measures that specific defect.

Found What it was
Slider off → Undo → Redo → Slider on gave a dangling slider The slot stash lived on the joint. Undo is a stack of URL strings, so every undo rebuilds every object — an undo and a redo that visibly changed nothing destroyed the thing the panel promised to remember. Now keyed by joint id, cleared on delete so a reused letter can't inherit someone else's slot.
An unparseable angle recursed until the stack ran out Two causes, both older than this branch: the angle was read off the pin, which has none, so the field was restored to the string NaN — and the restore emitted, so the handler ran again on its own unparseable output.
Per-joint memory leaked between projects Stashes and cylinder-skin preferences are keyed by joint letter, which is unique within a mechanism and meaningless across two. Opening a project in place handed its joint B whatever the last project's joint B remembered. A fresh load forgets them; undo says explicitly that it isn't one.
Recolouring a link left its weld plate stale Third of the same family as the grounded-angle bug: the glyph cache keyed on positions, and a Slide's plate is painted in its rider's own colour — so recolouring changed a mark while moving nothing.
A dangling block wasn't black It was pale blue with a red outline, to match the orphan-joint mark exactly. That broke "the block is #000 in every slider cell" for a cosmetic echo. Black with a red highlight now — the rule holds and it reads better.
Unused railStroke / tickStroke / arrowStroke / channelStroke constants They implied an R rule the drawing doesn't follow. Strokes go through scaleWithZoom like every other stroke in the app, and that's the right answer — a hairline that scaled with the geometry would vanish when zoomed out. The constants are gone rather than the behaviour.
The production marks run reported a skipped check as passing A check that reports success without executing turns a regression green.

It also confirmed independently: no Ground / Weld / Slider / drop path produces zero or two undo entries; joint snap beats a simultaneous link-body target; and no solver or analysis path returns a plausible-but-wrong result for a dangling slider — they refuse before solving.

Now: 539 unit tests, 27 gesture checks, 21 mark checks, 6 from-scratch checks, 8 Phase 3 checks. Marks and gestures also pass against the deploy preview's production build.

KohmeiK added 7 commits August 2, 2026 23:51
Every check so far looked at one pose. The marks are recomputed every timestep,
so a mark anchored to the wrong thing is correct in any single frame and wrong
across a sequence. Playing them found two:

- **A grounded guide travelled with its block.** The rails were drawn in the
  block's frame like everything else in the assembly, so the world-fixed track
  slid along with the thing that is supposed to slide through it. They get their
  own frame now, anchored where the guide sits at rest and spanning the travel
  the block actually makes over the solved timesteps -- which also retires the
  fixed 19.2R rail length the designer flagged as needing a number.

- **The weld plate filled the channel back in.** A link can be a slot carrier
  and a welded rider at once -- the Scotch yoke's yoke is both -- and the plate
  redraws that link, so it painted over the hole and the block appeared to ride
  on a solid bar. The plate now cuts the same channels the link does, expressed
  in the slot's own frame, with the same even-odd fill.

e2e/phase4-animation.mjs plays each mechanism and asserts across frames rather
than within one: that a grounded guide's transform never changes while its
block's does, and that a rider which is also a carrier keeps the cut. Restoring
either bug fails exactly its own check.

The animation bar is only rendered on the Analyze tab, which is why nothing here
had ever pressed play.
Reported by eye, and the eye was right: on a Scotch yoke the yoke's bar ran
three units *below* joint C when its rider runs three units above it, and
dragging a slot's defining joint left the block at an angle that was not the
slot's.

One cause. SliderMarkService.frame() emitted `rotate(-theta) scale(1 -1)` to
"undo" the y-flip on the holder above it -- but that flip is what turns model
coordinates into screen ones, and everything inside the holder is already in
model coordinates. The extra flip composed to a reflection: local +x landed on
model angle -theta, and local +y pointed the wrong way entirely.

Every mark in the set is symmetric about both axes -- block, channel, rails,
arrows -- so the mirror was invisible in all of them, and invisible in the yoke
at rest because its slot is vertical and a mirrored 90 degrees is still 90
degrees. It showed only in the weld plate, the one asymmetric mark, and in any
pose where the slot is off-axis. Which is exactly the two things reported.

Also: link outlines did not follow the object scale. A link's `d` is computed
once and cached, but its width is objectScale / 4, so changing the scale left
every bar at its old size while joints, ground marks and the whole mark system
grew around it -- worst on a slotted link, where the R-relative channel kept
scaling and outgrew the bar it is meant to be a hole in. The service now
recomputes link paths when the scale changes, wherever the change came from.

e2e/phase4-invariants.mjs states what has to be true of every mark and checks it
at rest, after dragging every joint in four directions, and at four object
scales: the block sits on its pin, its long axis runs along the slot, a floating
channel is centred between the joints defining it, and a weld plate reaches the
joint its rider reaches. Measured by mapping points out of each mark's own group
through the transform the browser actually applied, so it tests the drawing
rather than the numbers that went into it. Restoring the mirror fails it on
every off-axis slot.

Two flaws in that harness worth naming, both of which made it pass while
measuring nothing: it read an arc's radii as a coordinate pair, and it set
objectScale by assigning to a getter that has no setter.
Dragging a body translates every joint on it at once, which is a different path
through the code from dragging one joint -- and a slot's carrier moving
wholesale is the case most likely to leave its channel behind.

Aimed at the midpoint between a link's first two joints rather than at its
bounding-box centre: once a link has three joints its box is mostly the empty
space inside its hull, so aiming there grabs the canvas instead of the bar.
**A block could be dragged into its own carrier.** Grabbing the block and moving
it 25 px snapped its rider pin onto the nearer end of the bar it slides along.
The assembly then rode a link it was part of -- the slot's direction is measured
from two joints, one of which had become the block -- and it stayed
non-dangling and unflagged, because the slot's own well-formedness test looks at
the PrisJoint and the merge happens to its paired pin. Refused now, from either
direction, with a reason.

**A block stayed put when its slot moved.** This is what was reported by hand as
"the block doesn't follow the slot when the slot's joints are dragged", and it
is not the frame bug -- it survived that fix. A floating slider is deliberately
not a member of its carrier, which is what makes it a slot rather than a pin, so
nothing that drags the carrier or a slot-defining joint touches it: the channel
rotated away and left the block behind, by 0.17 of a unit dragging a joint and
0.41 dragging the bar. It is put back on the line it rides, keeping where it sat
along that line so reseating does not also move s0.

**Opening Settings threw on any mechanism with a slider.** A pre-existing loop
cast every Link to RealLink and called reComputeDPath, which throws on the first
SliderBlock and abandons every link after it. That is the job the service now
does, guarded, for every route that changes the scale rather than only the
panel -- so the broken loop is gone rather than duplicated.

The scale subscription is also guarded against the value it already has. A
BehaviorSubject replays its current value to each new subscriber, so an
unguarded handler rebuilt every link on construction and again on any re-emission
of the same number, which made the compound-contour caching test flaky.

phase4-invariants gains the fifth invariant: a floating block is ON the line it
rides. Removing the reseat fails it on four separate drags with the same
magnitudes the review measured.
CI caught what four clean local runs did not. `_objectScale` is a static
BehaviorSubject: subscribing to it in MechanismService's constructor replayed
the current value into every service the moment it was built, and left one live
subscriber per instance ever created. Under a test run that is one recompute per
accumulated service -- the spec asserting a welded contour is built once saw
fifteen, and the gallery round-trip timed out behind the work.

It is a method now, called from the one place that changes the scale without
rebuilding links anyway. Every other route -- a URL load, a template, undo --
constructs the links afresh, so their outlines already use the scale in force.
Re-projecting every floating slider on every drag meant dragging any joint
anywhere nudged every other block by the fraction it was already off its own
line -- joint coordinates come back out of the URL at a fixed precision, so a
mechanism is a hair off its own constraints the moment it loads. Below 1e-4 the
reseat leaves the slider exactly where it is: a thousand times finer than
anything visible, ten thousand times coarser than the breakages it exists for.

phase4-invariants gains an independence check: dragging a joint that touches no
slot must leave every block byte-identical. A slider's own pin counts as
touching, whether the slot floats or not -- a grounded guide travels with the
joint it sits on, so dragging that joint moving the block is the right answer
rather than a violation, which is what the first version of this check got
wrong on the elliptical trammel.
**The reseat never reached the solved frames.** It ran after updateMechanism,
which had already copied the stale pose into every timestep -- so the canvas was
right at rest and pressing Play snapped the block straight back off its channel,
by 0.02 on the yoke and 0.015 on the slider-crank. Reseating now happens before
the rebuild. This is the same defect reported by hand as "the block doesn't
follow the slot", surviving one layer deeper than the first fix reached.

**Object Scale accepted text and erased the canvas.** The validation pattern
used an unescaped dot, so `.` matched any character and "1x2" passed; Number()
made it NaN, and the NaN reached every mark -- the mechanism vanished behind
dozens of invalid-SVG errors and a crash reading 'joints' of undefined. The dot
is escaped and the scale must be positive, checked in the handler as well as the
pattern: every dimension in the mark system is a multiple of this number, so a
zero is as unusable as a NaN.

**Deleting a joint mid-drag kept dragging deleted topology.** The gesture stayed
live after the delete, and the next pointer move wrote through a SliderBlock
whose joint list no longer held what it was looking for. The delete cancels the
gesture, and the write is guarded, because a pointer move can still arrive
first.

The test harness resolves injector tokens by identity now. Answering every token
with one catch-all object made a service that was never provided look like one
that worked, right up until something called it.
@KohmeiK

KohmeiK commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Round 2: animation, dragging, and object scale

Reported by eye — "the Scotch yoke is visually clipping", "dragging D made it look weird", "slotted links break when you change object scale". All three were real, and the first two were one bug.

The slot frame was a reflection, not a rotation

SliderMarkService.frame() emitted rotate(-θ) scale(1 -1) to "undo" the y-flip on the holder above it. But that flip is what turns model coordinates into screen ones, and everything inside the holder is already in model coordinates — so the extra flip composed to a mirror: local +x landed on model angle −θ, and local +y pointed the wrong way entirely.

Every mark in the set is symmetric about both axes — block, channel, rails, arrows — so the mirror was invisible in all of them, and invisible in the yoke at rest because its slot is vertical and a mirrored 90° is still 90°. It showed in exactly two places: the weld plate, the one asymmetric mark, which ran three units below joint C when its rider runs three units above; and any pose where the slot is off-axis, which is what dragging D produced.

The plate's far end now lands on the joint its rider reaches, and a block's long axis matches its slot direction to 1e-3 on every mechanism.

Then five more, found by measuring what a picture cannot show

A grounded guide travelled with its block The rails were drawn in the block's frame, so the world-fixed track slid along with the thing sliding through it. Only visible in motion.
The weld plate filled its own channel back in A link can be a slot carrier and a welded rider — the yoke is both — and the plate redraws that link, painting over the hole.
Link outlines didn't follow the object scale d is cached but width is objectScale / 4, so bars stayed at their old size while the R-relative channel kept growing and outgrew the bar it is meant to be a hole in.
A block could be dragged into its own carrier 25 px was enough to snap its pin onto the bar it slides along; the assembly then rode a link it was part of, non-dangling and unflagged.
The block stayed put when its slot moved A floating slider is deliberately not a member of its carrier. Reseating it fixed the canvas but ran after the rebuild, so Play snapped it straight back off-channel — the same defect, one layer deeper.
Object Scale accepted 1x2 An unescaped . in the validation pattern. Number() made it NaN and the NaN reached every mark; the mechanism vanished behind dozens of invalid-SVG errors.
Deleting a joint mid-drag kept dragging deleted topology TypeError: Cannot set properties of undefined.

e2e/phase4-invariants.mjs

Rather than chase these one at a time, this states what has to be true of every mark and checks it exhaustively — 30 checks across four mechanisms:

  1. the block sits on its pin
  2. its long axis runs along the slot
  3. a floating channel is centred between the joints defining it
  4. a weld plate reaches the joint its rider reaches
  5. a floating block is on the line it rides — in the solved frames, not just on screen

…re-checked after dragging every joint in four directions, every link in four directions, and at four object scales — plus an independence check that dragging something unrelated leaves every block byte-identical.

Marks are measured by mapping points out of each mark's own group through the transform the browser actually applied, so it tests the drawing rather than the numbers that went into it. Every fix above has a mutation confirming its check fails when the bug returns.

Two flaws in that harness are worth naming, because both made it pass while measuring nothing: it read an arc's radii as a coordinate pair, and it set objectScale by assigning to a getter that has no setter.

Now: 545 unit tests · 30 invariants · 8 animation · 21 marks · 29 gestures · 6 from-scratch · 8 Phase 3. Animation and marks also pass against the deploy preview's production build.

§2.7 says selecting any member of a piston reveals it -- barrel, rod, or the
joint. Only the rod had a handler, so clicking the barrel did nothing and the
skin never expanded. It hands the gesture to the pin, which is the same assembly
and already knows how to reveal.

Ground triangles and input arrows stop hit-testing. They mark what a joint is
rather than being controls, and both are drawn wider than the joint itself -- so
they sat over whatever was beneath them and swallowed clicks meant for a link
body. Found by a barrel that could not be clicked because a ground mark
overlapped it.
@AnselChang

Copy link
Copy Markdown
Contributor

AI writing its own playwright tests for internal self-validation has definitely been a game changer for vibe coding

@KohmeiK

KohmeiK commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Yeah it's definitely come a long way since the last time I touched PMKS

KohmeiK and others added 3 commits August 4, 2026 01:37
Seven of the fifteen reported UI defects, all in what gets drawn.

**Corner arcs took the wrong side.** Which way a link outline turns each
corner follows from the winding of the outline; it was guessed from one
coordinate of the first corner and then flipped for links with more than
three joints, which is not a property of the outline at all. `hull` does
not return a consistent winding, so on the hulls where the guess
disagreed every corner arc took the short way round the *other* circle
and drew a concave bite where a rounded corner belongs. Derived from the
signed area now, with the near-collinear four-joint hull an adversarial
pass found as a regression case.

**The weld plate was four shapes at three widths.** A capsule fitted to
the rider, the block laid over it, and two fillet wedges patched into
the internal angles -- every seam between them visible through the
plate's own alpha, and the capsule 10% wider than the rider it stood in
for, so it haloed the link all the way round. It is one Boolean union of
the rider's *real* outline with the block now, and the rider's ordinary
drawing is suppressed while the plate stands in for it. `barHalf` was
1.84R off a mockup; the bar is objectScale / 4, which is 5/3 R, and the
slot-drop radius was reading the same wrong number.

That union kept falling back to emitting its inputs side by side,
because `flattenPath` refused `H`, `V` and `Q` -- and every rectangle
and capsule in the mark system is written with the shorthand.

**A pinned rider drew under its own block.** The link layer is under
every block on the canvas, so a coupler ending at a slider vanished
behind the block for the last bar-width of its length. Riders are drawn
in their block's own frame at layer 4 now; the joint marker is later
still, so the pin stays on top of both.

**Two slots that cross filled their crossing back in.** Both holes are
appended to one path and filled even-odd, so the crossing is wound three
times, comes out odd, and renders as a carrier-coloured diamond in the
middle of the X. Unioned before subtraction.

**Two grounded guides that cross painted a knot.** Where one runs
through another its rails are broken and its hatch is dropped, which is
the drawing convention for the member passing behind.

**The ground hatch did not match between a pin and a slider.** Rails ran
at a constant screen width while the pin's ground symbol is an asset
sized in model units: across a 25x zoom range the rail went from half
the hatch's weight to twelve times it. Rails now use the asset's own
line weights, the one deliberate exception to scaleWithZoom.

**Slots reached into their own joints.** 1.8R put the channel's end cap
0.27 objectScale from a joint drawn at 0.2; 2.8R leaves the margin of
bar the reference shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The white slivers were the outline folding over itself.** `hull` returns
a joint that lies on the line between two others as a vertex of that
edge, which is defensible for a hull and wrong for an outline: the offset
edge arrives at it, turns through a semicircle it does not need, and
leaves along the same line. Filled even-odd, the doubled region cancels
and renders *white* — and a joint only has to pass through its
neighbours' line to produce it, which is exactly "occasionally, while
dragging". Vertices that sit between their neighbours are no longer
corners. A hull with no area also has no winding to read a sweep from, so
it falls back to the rule a bar uses.

Measured rather than argued: `e2e/phase4-corner-arcs.mjs` drags six
joints through four directions each and checks every corner arc of every
link against its own outline's winding, at every step. 216 frames, and
the two configurations it used to fail on are the flat hull above and a
channel subpath the check was wrongly reading as part of the outline.

**A block can be pulled out of its slot.** Sliding along the slot is by
far the commoner intent, so the block stays on its line through any
sideways wobble; past four bar-widths it lets go, leaving the dangling
block — a slider with nowhere to slide, drawn red until it is dropped on
a link again. The slot is stashed on the way out, so putting it back on
the same bar is a drop rather than a rebuild.

**A drag squares itself against a neighbour's axis.** Per axis and
independent, so one drag can land on one joint's x and another joint's y
at once, with a broken line to whichever joint supplied it. Tolerance is
a distance on screen, so it feels the same at every zoom. Only a free
drag: a block is already constrained to its slot, and a capture has a
target of its own.

**Slider inputs.** A driven block translates, so Input Settings now say
so: direction along the slot rather than clockwise, and length per second
rather than RPM. The speed was reaching nothing at all — `Mechanism`
divided a *rotational* step by it whatever the input was, so a slider ran
on a clock unrelated to the number in the box. The arrow the block sets
off along is drawn larger, because two matched arrows can say "this
translates" but not which way it goes first.

**Make Input** is built from the same block as every other button in the
panel, and its icon has the viewBox it was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A study of what it takes to make one, and two things it turned up.

**The collinearity test was 1e-6.** Right for the float error a solved
position carries, hopeless as a rule for something a person draws:
nothing placed with a mouse lands within a millionth of a unit of a line,
so a cylinder could be opened from a URL and never built by hand. It is
half a block's width across now — anything inside that is behind the
block the skin draws over it, so the straight part the skin shows is
straight on screen either way. (Axis snapping, added alongside, makes an
axis-aligned piston exact rather than merely close.)

**The skin picker appeared only once you already had a cylinder.**
`showCylinderSkin` required the shape test to pass, so the "Cylinder"
button was invisible to exactly the people who needed it and could only
ever turn a cylinder *off*. It is offered to any welded slider now, with
Cylinder greyed and the reason beside it — which makes it the one place
in the app that says what a piston is made of.

Two Phase 1 checks were failing on this branch and are now fixed. Landing
a joint on another joint cut a slot into whatever else passed through
that point, giving the four-bar a fifth joint: a joint you are not
allowed to merge with is still a joint you are aiming at, so no slot is
offered while the drop point is on one. And the Weld check still looked
for the button pair that Phase 4 replaced with a toggle.

The invariants script read the weld plate at a fixed path index and read
a carrier only in the link layer, neither of which survives a rider being
drawn by its own plate. It measures the plate's furthest point from the
pin and follows the carrier to whichever element is drawing it.

Full suite: 563 unit, 21 marks, 35 gestures, 30 invariants, 8 animation,
6 from-scratch, 216 corner-arc frames, 11 sticky/snap, 8 Phase 3, 6 Phase
2, 64 Phase 1.

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

Copy link
Copy Markdown
Contributor

would be neat to be able to see exactly what requirements are missing when a linkage is currently invalid

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.

2 participants