Skip to content

feat(views): draw boundary ports as schematic symbols, not boxes - #23

Open
endrix wants to merge 6 commits into
fix/boundary-port-type-and-navigationfrom
feat/boundary-port-symbol
Open

feat(views): draw boundary ports as schematic symbols, not boxes#23
endrix wants to merge 6 commits into
fix/boundary-port-type-and-navigationfrom
feat/boundary-port-symbol

Conversation

@endrix

@endrix endrix commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Stacked on #22#21. Merge in order and GitHub retargets each one.

Implements panels 4a and 4b. Boundary ports were rounded pills, 120×50, name centred with the type under it — and the wire met the pill at its vertical centre, which is the gap between the two lines, so it ran through the middle of the label.

They are now schematic port symbols.

before after
body rounded pill, 120×50 none — an arrow glyph on the axis
input glyph arrow, 8 × 9
output glyph arrow 7 × 9 + bar 2 × 11
name centred, 11px on the axis, 9.5 / 12, outward-aligned
type centred below, 9px second line, 8 / 10, #a8a8a2
row height 50 22
wire node's vertical centre — between the lines the name's line

Names right-aligned on inputs, left-aligned on outputs, so both text columns run away from the glyph column into the margin and the glyphs stay in a straight line.

Why node width stays fixed

The glyphs line up only if the nodes' inner edges do, and ELK's alignment inside a layer is not something to rely on for that. Keeping one width across every boundary node makes their inner edges align regardless, and text that outgrows the box overflows toward the sheet edge — empty space. Sizing each node to its own text would have been the obvious move and would have broken the column.

Geometry lives in shared

Alongside the port anchor, for the same reason. The two halves are computed in different processes — the server sizes the node and places the port the routers anchor to, the client draws the arrow — and nothing reconciles them at runtime. Disagree by a pixel and the wire ends near the arrow instead of on it, which no error reports and no existing test would catch.

That is what the new suite pins: the anchor the routers compute must land exactly on the arrow's tip (anchor.x === node.width for an input, 0 for an output) and on the axis. Mutation-checked by putting the anchor back on the node centre — the axis test fails, which is precisely the old bug.

Two decisions the spec left open

  • Selection had nowhere to go. The pill was what showed selected and hover. That moves to the hit target — the 20×22 rectangle the spec already calls for, transparent until selected or hovered.
  • The port element would have doubled the arrow. It stays in the model as the edge anchor but is no longer drawn.

One deviation worth flagging: the type is no longer tinted by direction (it was green/blue with the name). The spec gives it #a8a8a2 on both sides, and colouring it made it compete with the name — but if you wanted the tint kept, say so.

Not done: wire 1.6px on the axis. Edge stroke width is global, not per-port, so changing it would restyle every edge in the diagram. Happy to do it as its own change if that is what the row means.

Verification

diagram-server 226 tests, diagram-client 93, sidecar 184, typecheck 5/5, bundles build.

The extension-core snapshot and neutrality Gate 1 are still red here — both fixed by #20, not in this branch's ancestry, neither file touched.

endrix added 6 commits August 31, 2026 16:31
A network's inputs and outputs drew as rounded pills, 120x50, with the name
centred and the type under it. The wire met the pill at its vertical centre —
which is the GAP between the two lines of text, so the wire ran through the
middle of the label.

They are now schematic port symbols: an arrow glyph sitting on the wire's own
axis, with the text running outward from it, away from the wire. An input is a
bare arrow; an output is an arrow stopping against a bar, the way a terminal is
drawn. The name sits ON the axis, so the wire enters on the name line; the type
sits on a second line below, clear of the wire entirely.

Names are right-aligned on inputs and left-aligned on outputs. Both text
columns therefore run away from the glyph column into the margin, and the
glyphs stay in a straight line. Node width stays fixed across every boundary
node for exactly that reason: their inner edges line up whatever ELK does with
alignment inside a layer, and text overflowing into the margin is harmless.
Height drops from 50 to a 22px row pitch, a little under half.

The geometry lives in shared, alongside the port anchor and for the same
reason. The two halves are computed in different processes — the server sizes
the node and places the port the routers anchor to, the client draws the arrow
— and nothing connects them at runtime. Disagree by a pixel and the wire ends
near the arrow rather than on it, which no error reports.

Two things the spec calls for that needed a decision rather than a number. The
pill was what showed selection and hover, so that moves to the hit target: a
20x22 rectangle centred on the glyph, far larger than the 8px arrow so it can
be grabbed, and only one row tall so that hiding the type line at low zoom
cannot move the drag target. And the port element stays in the model as the
edge anchor but is no longer drawn, since the glyph would otherwise be doubled.

The type line is no longer tinted by direction. It is supporting text on its
own line, and colouring it made it compete with the name.
…symbol up

Three faults from the first cut, one of them mine to have caught before it
shipped.

The name rendered ON TOP of its own glyph. The text was drawn inside a group
translated by `label.position` — inherited from the old code, where it was
equally meaningless. These labels carry no layout feature precisely so that
nothing but the view positions them, which means their position is simply
whatever it was last left at; translating by it moved the text off the
coordinates the view had just computed. The text is now drawn directly, with no
wrapping transform.

The type took the name's colour. It carries both `boundary-label` and
`boundary-type-label`, so `.boundary-input .boundary-label` matched it too — at
the SAME specificity, two classes each, leaving document order to decide. The
direction rules came later and won, and the type rendered green on inputs and
blue on outputs. Qualifying with both classes outranks them outright, and the
rule now also sits after them so a reorder cannot recreate the tie.

That one is worth a test rather than a fix alone, because a colour assertion
would not have caught it: the declaration was always present and always
correct, and what was wrong was whether it applied. So the test asserts the two
things that decide that — specificity, and that it is not leaning on order.
Writing it turned up a second-order version of the same trap: the first draft
scanned the raw file and matched the selector inside the comment EXPLAINING the
rule. It strips comments now, so only what the browser sees is scanned.

Finally the whole symbol was too small. The drawing it came from is set at a
finer weight than the diagram renders at, so a 9.5px name and an 8px arrow read
as fine print beside a 3px wire. Everything is scaled by about a quarter, in
proportion: the arrow still spans most of the name's line, the type still sits
a little under two thirds of the name, and the row grows 22 to 28.
…look like a node's

Two changes with one shape: a boundary port is now drawn as ONE object by the
node's own view, the way entity nodes already draw themselves.

The name was a separate label element rendered by the label view. That is what
made it float: an element positioned by nothing still carries whatever position
it was last left with, and two pieces of code — the label view placing text and
the node view placing the arrow — had no way to agree on where the symbol was.
Removing the transform fixed the symptom; drawing the name in the node view,
beside the glyph it belongs to, removes the possibility. Glyph, name and type
are now built together from the node's own args.

The type reuses the NODES' footer class rather than styling of its own, so a
port's type reads exactly like an entity's — one definition of what a type
looks like instead of two that can drift.

That also retires a specificity fight rather than winning it. The type label
carried both `boundary-label` and `boundary-type-label`, so the rule tinting a
NAME green matched it too at identical specificity; order decided, and the type
came out in the name's colour. Nothing tints it now because no direction
selector can reach it, which the test states as the rule: what tints by
direction may reach the name and the glyph, and nothing else.

The label elements stay in the model — a rename addresses `<node>_label_name`,
and the server owns them — but render nothing. One consequence worth naming:
double-clicking the name ON THE CANVAS to rename went with the rendered label.
Renaming from the property panel is unaffected. If canvas rename is wanted back
it should be a double-click on the port symbol itself, which is a listener
rather than a label.
The name kept rendering centred on its own glyph, through two attempted fixes.
Neither was the cause.

A presentation attribute loses to any stylesheet rule, and upstream
GLSP/Sprotty styles set `text-anchor: middle`. So `attrs: {'text-anchor':
'end'}` was simply ignored: the text centred on the x it was given, which put
the arrow about halfway along the name every time. Removing the label's stale
transform and then moving the text into the node view were both real
improvements, and both left this untouched, which is why the picture barely
changed.

Setting it as an inline style wins.

This was already known here. The port labels forty lines further down carry the
fix and a comment saying exactly why — "Presentation attributes can be
overridden by CSS... Force via inline style." I wrote new text-drawing code in
the same file without reading it, then explained the resulting overlap twice
with theories that fitted the symptom.

So the test asserts the STYLE specifically rather than that anchoring exists at
all: an assertion that merely found `text-anchor` in the source would have
passed for the whole life of the bug.
The name was drawn by the port's own view, but the port was not grabbable by
it: the hit target was a 24px box centred on the glyph, so clicking or dragging
a port by its name did nothing, and selecting one outlined the arrow while the
name sat outside the outline. Drawn as part of the symbol, behaving as a
separate thing.

The target is now the node's whole row. Its width comes from the node rather
than a constant, which is the part that matters — any fixed number is a box
around the arrow again, just a wider one.

The small box had a real reason behind it, which the row keeps: a 10px arrow is
hard to hit, and the target must not depend on the type line, since that can be
hidden at low zoom. The height is still the fixed row pitch and still measured
from nothing, so hiding the type moves nothing.

Text that overruns the node's width is not covered — the box is 120 wide and the
text runs outward into the margin past that. It affects only unusually long
names, and widening the node to fit each one would break the alignment the
column depends on.
… size the target to the text

Two faults, both from treating the port symbol as decoration laid beside the
diagram rather than as part of it.

An edge ending at a boundary port drew its own arrowhead on top of the port's.
They do not merely duplicate each other: the edge's tip is deliberately nudged
four pixels PAST its endpoint so it meets the stroke cap, and that endpoint is
the glyph's own edge — so the arrowhead came to rest inside the glyph. The
drawing shows a plain line running into the symbol, which is what a schematic
does, because the symbol is already the arrow. Suppressed in `renderArrow`
itself so every edge view that inherits it is covered.

The grab target was the node's box, a fixed 120 wide. That width exists so the
glyph columns line up and says nothing about how wide any one port reads, so a
short name left dead clickable space beside it while a long one overflowed the
box and stopped being clickable at the very point it became visible. The target
now follows the text, out from the glyph.

That needs a text width, which the model cannot measure, so it is estimated.
Acceptable only because nothing about the drawing depends on it — glyph, text
and anchor are all placed from fixed geometry, and being a few pixels out makes
the target slightly generous or slightly tight while moving nothing. Marked as
never to be used for layout.

The arrowhead test is worth noting. Written first as a search of the source for
the guard, it passed with the guard disabled by `false &&` — it was checking
that the words were there, not that they did anything. It now calls
`renderArrow` and asserts what comes back, with an ordinary node as the control,
and fails under that same mutation.
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