Skip to content

fix(graph): relationship labels stay readable after Organize and in hierarchical - #280

Open
mvalancy wants to merge 29 commits into
devfrom
fix/label-clear-layout
Open

mvalancy wants to merge 29 commits into
devfrom
fix/label-clear-layout

Conversation

@mvalancy

@mvalancy mvalancy commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Relationship (edge) type labels went missing after "Organize around connections", in the hierarchical layout and once the force layout settled. The renderer never draws a label over a card or another label: it drops a label it cannot place. Labels were dropped for two reasons. They were hidden below 60% zoom, and Organize and the hierarchical fit frame most graphs further out than that. And the layouts left no room along an edge for its label. This PR is the minimal fix for what the owner reported, "Organize around connections" and the force layout; the hierarchical layout stays as on dev. Label room in the hierarchical layout, icon chips, hub placement, and lines through cards or on top of each other are in #283.

What changes

  • Labels show while card titles do. The label cutoff is now CARD_TITLES_HIDE_BELOW (0.34) instead of 0.6. It lives in the new lib/cardTitleZoom.ts, and the zoom-out levels of Zoom out: labelled levels from items to graphs to projects (#277) #279 (merged from dev) now take it from there, so cards and labels share one cutoff. Labels keep their 14 px screen size at any zoom.
  • Small graphs are framed whole on desktops and tablets. For graphs of up to 60 items (WHOLE_FIT_MAX_CARDS), Organize frames the whole graph, down to that 0.34 zoom. This replaces the 0.65 floor that cut part of the graph off. Where even 0.34 is not enough, the camera holds 0.34, centred on the graph when it nearly fits and on the busiest item's neighbourhood otherwise. Graphs over 60 items keep the plain organizer, the 0.65 floor and the overview/LOD behaviour.
  • Phones keep their floors. On a 390 px phone, two compact cards and a label side by side do not fit at any zoom where the cards' titles still fit whole. Below about 0.45 "System coordinator" breaks to "coordinato r", and at 0.5 the hub card is 131 px wide where topology-organization asks for 132. So after Organize a phone keeps dev's camera: the 0.65 floor, centred on the busiest item's neighbourhood, and Organize leaves label room for that zoom. Fitting a whole graph on a phone needs compact label chips (Layouts: compact label chips on phones, hub placement in hierarchical, wires through cards #283).
  • Layouts leave room for every label. Each edge's visible length fits its measured label at the zoom the camera will frame it (labelClearance.ts). That zoom comes from the layouts at 100% and at the lowest zoom, plus at most two more (regula falsi). If wires still cross labels, a component spreads evenly by the smallest factor that frees them. A tall organized layout on a wide screen, or a wide one on a tall screen, is turned a quarter turn when that frames it closer without putting cards on cards or hiding a label. After the force layout settles, only cards the simulation placed itself are moved, only for labels with such a card at one end, and a move is kept only when it shows more of those labels; saved positions never move.
  • Labels do not depend on the item ids. Real ids are random and break ties in the organizer and in the label order. A spread is now kept only where it was checked, and each finished layout (after packing and the quarter turn) is checked as a whole. The renderer places each label from where it was in the previous frame, so the spots picked while the camera moves can box a label out. In a graph with at most 60 relationships it now also tries a fresh placement, the one the layouts leave room for, when a label on screen found no spot (labelsMissed).
  • Bounded work. All of this runs in the click handler, so it is limited to 60 cards, LAYOUT_CHECKS label checks and five layouts per click: about 100 ms for 60 cards and 90 relationships on a desktop.
  • Labels are checked as turned boxes (oriented, separating axes) with every spot along the edge tried, so a turned label is no longer refused next to a card it does not touch. Each placement first keeps only the obstacles and wires that can reach it, and tests exact axis-aligned bounds before the turned-box test.
  • Card titles never split a word. A compact card is at most 240 graph units wide with its title kept at 14 px, so below about 0.45 a long word no longer fits a line, and fitGraphText split it ("coordinato r", "Design mockup s"). Organize rests at 0.34 on a desktop, so the first press showed it. A word wider than its line is now cut and ends in "…" ("coordinat…", "Design mocku…") at every zoom. A path, URL or identifier wider than a line (anything but letters inside its punctuation, or camelCase) breaks inside instead, after a separator or before a capital, and shows in full while lines are left ("Update / packages/web/src/lib/ / labelClearance.ts"); "…" only ends the last line. Scripts written without spaces still break between characters. Unit tests wrap seven titles at widths 40 to 320 and fail if a line starts or ends inside a word (before the fix: "Sys" / "tem"), and check that paths, URLs and identifiers come back whole; the journey fails a stage when a card title line on screen splits a word.
  • The "Show Mini-Map" button is now an obstacle for labels and the fit. On iPad a label sat under it.

Review fixes (since the first review)

  1. Freeze on Organize (1.4 s for 60 cards, 2.9 s for 200): the search is skipped above 60 cards (plain layouts, as before) and bounded below that. Unit guard: Organize of 60 cards / 90 relationships finishes within a second (about 100 ms on a desktop); above 60 cards it equals the plain organizer at floors 0.34 and 0.65.
  2. Unplaced graphs of any size got the 0.34 spread: the 60-item bound now applies whatever the floor, and to clearing labels after the force layout settles. (Since the second review, unplaced graphs open exactly as on dev; see below.)
  3. Vacuous journey check: every checked stage must draw every relationship of the graph (drawn === edges) and have at least one edge with both cards on screen.
  4. Hierarchical → force kept the whole-graph framing: restored, and since the third round the hierarchical layout and its framing are dev's.

CI on the first push also found:

  • organize-labels waited for a filled Welcome graph that earlier CI tests had emptied. It now waits only for the graph to exist.
  • item-symbols: two parallel labels beside a diagonal wire were flagged as overlapping because their axis-aligned bounds overlap, while their outlines are 34 px apart. The check now uses the turned outlines.
  • topology-organization at 390 px: the hub card must be 132 px wide, and on a phone framed whole at 0.34 its title broke mid-word ("coordinato r"). Phones now keep their floors (above), and the test is dev's, unchanged.
  • large-symbols: one zoom took 5.1 s against the 5 s limit. A browser profile of that zoom is dominated by layout reflow (getBoundingClientRect in SemanticGraphOverview and updateEdgePositions), not by label placement. It passed on the next push.

Local journey runs then showed the launch checklist on desktop losing one label in about half the runs (the ids decide the order). The unit test label room whatever the item ids covers five id orders that hid a label before (5 of 10 cases failed), and the renderer's fresh retry covers the order in which the camera's path placed labels. After both, the journey passed twice in a row locally.

Second review fixes (re-review at 63c8f28)

Rule for this round: what the owner's report does not need goes back to dev's behaviour. Each item has a test that failed before and passes now.

Reverted to dev or fixed Test (red before → green now)
Settled force layout threw a free card away when two saved cards hid their own label (4× then 16× its distance, then saved) Fixed (clearFreeCards): only labels with a free card at one end count; steps and the spread are kept only when they show more of those labels, the spread moves only the free cards of those labels' groups and must show all their labels; a second pass moves nothing one new card in a saved graph whose saved cards hide a label: the card stays put when its label shows (before: (600, 400) → (2170, 1200)); when its own label is hidden it moves less than 1.5 label gaps from its neighbour (before: 1746 units away) and does not move again over two more passes
Hierarchical rows no longer wrapped (hub with 100 dependents 25564 units wide instead of 988; with 20, 4828 instead of 988) Reverted at every size (third round): the hierarchical layout, its framing and layoutAlgorithms.ts with its tests are dev's, byte for byte; the label-room rows and their tests are gone dev's own tests; a hub with 100 dependents is dev's 988 × 3680 again
Unplaced graphs first opened spread for labels at zoom 0 ("Plan a launch" at 0.20) Reverted: the first open uses dev's organizer and framing Journey a graph of unplaced items opens no smaller than its titles and labels show: "Plan a launch" unplaced rests at ≥ 0.34 (with the old line: 0.197)
Paths, URLs and identifiers lost their tail at full size ("labelCle…") Fixed (fitGraphText): such tokens break inside while lines are left; ordinary words still never split shows a path, URL or identifier wider than the line in full… (failed on 63c8f28)
Fresh-spot label retry ran where it could not help (labels off screen or past the 32 cap) Limited: runs only when a label on screen was tried and found no spot (labelsMissed) a label is missed only when one on screen was tried and found no spot (the old condition counted an off-screen label and a capped one as missing)

Third round: the hierarchical layout is dev's at every size. The owner reported Organize and the force layout, and the label-room rows made a common shape, an epic with 20 tasks, 4828 units wide instead of 988 with the camera on the top rows. Label room in the hierarchical layout and the review's remaining notes are on #283.

Test

organize-labels is a blocking sequence in the pr/full profiles and the "toolchain, schematic, LOD, layout" CI shard, run on desktop and iPhone. It creates Welcome and a twelve-item launch checklist with fifteen relationships of mixed types (the graph that was reported), presses the real Organize control (on a phone, in the View sheet) and checks the view it rests on. It also opens "Plan a launch" with every item unplaced and checks the camera rests no lower than 0.34. At the camera the app rests on, edgeLabelAudit.ts reads every label from the DOM. It fails a stage if:

  • not every relationship of the graph is drawn;
  • an edge with both cards on screen has no label;
  • a label's text is under 11 px;
  • a label is under a card or on another label;
  • a label is not hit at its centre (elementFromPoint) or lies outside its edge's visible gap;
  • no edge has both cards on screen (except the launch checklist on iPhone; see Layouts: compact label chips on phones, hub placement in hierarchical, wires through cards #283);
  • any card is off screen while the camera rests above the floor (0.34; on iPhone 0.65).

It measures once the "Layout saved" note has faded, at the view a person is left with.

Unit tests (labelClearance.test.ts) place labels the way the renderer does, over whole layouts of the example graphs and the hard shapes (a hub with 12 spokes, two hubs, a long chain, a dense cluster of 30, long labels) on three screen sizes: none is dropped. They include five id orders of the launch checklist and two connected hubs that hid a label before.

Red before (unchanged dev 3a629e2)

This is the first version of the journey run against the unchanged source. That version also measured the force layout after Organize ("force after") and graphs of unplaced items ("force (unplaced)"); the journey now checks Organize, the hierarchical layout and the first open of an unplaced graph. Each cell is the labels dropped out of the edges whose two cards were both on screen. No label was ever drawn over a card or another label: the symptom is always a missing label. "zoom" marks a camera below the old 0.6 cutoff, where every label was hidden.

Graph Stage Desktop iPad iPhone
Welcome organized 3 of 3 3 of 3 3 of 3
Welcome hierarchical 3 of 3 3 of 3 3 of 3
Welcome force after 3 of 3 3 of 3 2 of 2
Welcome force (unplaced) 1 of 3 1 of 3 1 of 1 (zoom 0.5)
Plan a launch organized 7 of 8 4 of 7 0 of 1
Plan a launch hierarchical 5 of 7 5 of 5 4 of 4
Plan a launch force after 5 of 7 2 of 5 0 of 1
Plan a launch force (unplaced) 10 of 11 11 of 11 (zoom 0.589) 2 of 2 (zoom 0.5)
Fix bugs organized 3 of 3 3 of 3 2 of 2
Fix bugs hierarchical 2 of 2 1 of 1 2 of 2
Fix bugs force after 2 of 2 2 of 2 0 of 0
Fix bugs force (unplaced) 3 of 3 3 of 3 (zoom 0.562) 0 of 0 (zoom 0.5)
Move house organized 2 of 3 0 of 3 2 of 2
Move house hierarchical 3 of 3 2 of 2 3 of 3
Move house force after 2 of 2 0 of 1 1 of 1
Move house force (unplaced) 1 of 3 1 of 3 2 of 2 (zoom 0.5)
Hub with 12 spokes organized 4 of 11 0 of 6 0 of 1
Hub with 12 spokes hierarchical 10 of 12 12 of 12 8 of 8
Hub with 12 spokes force after 0 of 4 0 of 1 0 of 1
Hub with 12 spokes force (unplaced) 12 of 12 (zoom 0.561) 7 of 12 8 of 8 (zoom 0.5)
Two connected hubs organized 6 of 12 8 of 11 1 of 1
Two connected hubs hierarchical 13 of 15 12 of 12 9 of 9
Two connected hubs force after 7 of 12 1 of 5 0 of 1
Two connected hubs force (unplaced) 15 of 15 15 of 15 (zoom 0.525) 0 of 0 (zoom 0.5)
Long chain organized 11 of 11 11 of 11 2 of 2
Long chain hierarchical 6 of 6 3 of 3 2 of 2
Long chain force after 7 of 7 2 of 2 2 of 2
Long chain force (unplaced) 11 of 11 (zoom 0.593) 9 of 11 4 of 4 (zoom 0.5)
Dense cluster of 30 organized 11 of 18 5 of 9 0 of 2
Dense cluster of 30 hierarchical 33 of 33 23 of 23 0 of 0
Dense cluster of 30 force after 7 of 14 3 of 8 0 of 1
Dense cluster of 30 force (unplaced) 47 of 47 (zoom 0.402) 47 of 47 (zoom 0.441) 2 of 2 (zoom 0.5)
Long relationship labels organized 3 of 8 3 of 9 0 of 1
Long relationship labels hierarchical 10 of 10 4 of 4 5 of 5
Long relationship labels force after 0 of 5 0 of 4 0 of 0
Long relationship labels force (unplaced) 8 of 10 10 of 10 (zoom 0.561) 2 of 2 (zoom 0.5)

Welcome is the graph a new user sees first. Before this change, all 3 of its labels were dropped after Organize on every device, and in the hierarchical layout too. On iPhone, Organize framed it at 0.65, with part of the graph off screen.

After (this branch after merging dev 25a6a5e, local run of organize-labels: 6 passed, desktop and iPhone)

Measured at the camera the app rests on after Organize. "Area" and "mean edge" are in graph units, centre to centre. "Edges in view" have both cards on screen, clear of the controls; none of them lost its label.

Graph Device Labels shown Edges in view Zoom Cards on screen Area Mean edge
Welcome Desktop 3/3 3 1.098 4/4 308k 409
Welcome iPhone 1/3 1 0.65 2/4 344k 476
Launch checklist Desktop 13/15 11 0.34 10/12 11332k 1012
Launch checklist iPhone 4/15 1 0.65 2/12 3739k 554

"Plan a launch" with every item unplaced opens at 0.374 on desktop (12/12 cards on screen) and 0.5 on iPhone, as on dev. Like dev, that first view hides most of its labels (1 of 11 shown on desktop) until the person presses Organize.

Spread for Welcome went from area 301k and mean edge 300 (desktop, organized, before) to 308k and 409. On iPhone, Organize frames it at 0.65 as before, now with the label of the edge on screen shown (before: all dropped). Earlier local runs: item-wires 13 passed (topology-organization at 390 and 1440 px included), large-symbols passed, semantic-lod passed (earlier push), web unit tests 83 files / 825 tests passed, runner CLI tests 43 passed. After the third round (hierarchical as on dev): the affected unit suites (labelClearance, layoutAlgorithms, graphSymbolLayout, edgeLabelPlacement, topologyOrganization, edgeRouteGeometry, localItemLayout, graphFit) 177 tests passed, tsc clean, runner CLI tests 46 passed, playwright test --list lists the journey's 6 tests.

Before and after: Welcome, Organize

Before After
Desktop
iPhone

Launch checklist on desktop after Organize: 14 of 15 labels, none dropped among the 13 edges on screen.

Not in this PR: #283

  • Compact label chips. On a phone, 14 px labels on every edge need more screen than a 12-item graph has, so phones keep their floors (above) and show the busiest part. Even dev's organizer frames the launch checklist whole only at 0.25 on iPhone, before any label room. An icon chip where a full label cannot fit would let the whole graph show.
  • The hierarchical layout is dev's: no label room yet, and only DEPENDS_ON sets the rows, so a hub with mixed relationships (Launch plan) sits at the bottom.
  • Lines through other cards, and lines on top of each other. For example, Press kit → Email campaign → Launch plan lie on one line after Organize.
  • Card titles in Zoom out: labelled levels from items to graphs to projects (#277) #279 come back only above 0.38. A graph organized to rest between 0.34 and 0.38, after the person had zoomed out past 0.34, shows labels while titles are still hidden until the next zoom.

🤖 Generated with Claude Code

mvalancy and others added 6 commits November 10, 2026 22:48
A relationship label follows its edge, but the placement checked it as an
upright box, so a turned label could be refused next to a card it did not
touch, or let through over another turned label. Labels are now checked as
oriented boxes (separating axes; touching is clear), each candidate spot
along the edge is tried nearest-first, and the search budget covers a
whole organized graph (64 labels, 160 attempts) so labels are not dropped
for running out of tries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
organizeTopology takes an optional edgeGap (the visible length an edge
needs between its two cards) and spread hook. Chains, cycles, rings and
hubs use the gap of each pair instead of one fixed 64 px; with no options
the positions are exactly the old ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
computeCompactLayout takes an optional edgeGap. Linked cards in a row and
rows joined by edges get the gap their labels need; only rows with no edge
to another row wrap (chains snake), rows are ordered by barycentre sweeps
to cut crossings, and each card is aligned under its neighbours with
isotonic regression so edges stay short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
labelClearance.ts places every label as the renderer does over a whole
layout and makes room where one would be dropped: Organize and the
hierarchical rows reserve labelGap per edge at the zoom the camera will
frame them, then spread by the smallest factor that frees every label
(rows apart first). clearFreeCards moves only the cards the force layout
placed itself. Groups over 200 cards keep only the reserved gaps.

labelClearanceShapes.ts holds the new-user graphs and hard shapes (hubs,
two hubs, a long chain, a dense cluster of 30, long labels) shared by the
unit and browser tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Organize and the hierarchical toggle lay out through labelClearance with
the measured label of every drawn edge and the camera framing; the force
link distance uses the measured label, and once the simulation settles
clearFreeCards frees labels by moving only unpinned cards. A new layout
forgets the remembered label spots so labels are placed afresh. The
"Show Mini-Map" button is now an obstacle labels and fits keep clear of.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A blocking browser sequence presses the real Organize control (the View
sheet on phones), toggles hierarchical and back, and lets the force
layout settle from unplaced items, on the example graphs and hard shapes.
edgeLabelAudit reads every relationship label from the DOM (turned boxes,
elementFromPoint, the visible gap) and counts dropped, covered and
overlapping labels. Registered in the pr and full profiles and the
layout CI shard; lesson recorded in the agents inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

mvalancy and others added 2 commits November 10, 2026 22:59
Relationship labels disappeared below 60% zoom, so after Organize or the
hierarchical fit (which frame a graph further out) most of them were gone.
They now show while card titles show (CARD_TITLES_HIDE_BELOW, 0.34, shared
with the zoom-out levels of #277 through cardTitleZoom.ts), at their full
14 px on screen.

Graphs of up to 60 items are framed whole after Organize and in the
hierarchical layout, down to CARD_TITLES_SHOW_ABOVE (0.38), instead of the
0.65 floor (and the phone's 0.5) that left part of the graph off screen.
Each layout leaves label room for the zoom it will be framed at, found by
bisection, and an organized layout taller than a wide screen (or wider
than a tall one) is turned a quarter turn when that frames it closer and
moves no card onto another or hides a label. Larger graphs keep the 0.65
floor. The label placement budget is back to dev's 32 labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd text size

The journey now runs on Welcome and the twelve-item launch checklist
(fifteen relationships) on desktop and iPhone. At the camera the app rests
on after Organize and after the hierarchical fit, every edge with both
cards on screen must show its label with text of at least 11 px, clear of
cards and labels; the whole graph must be on screen unless the camera is at
the smallest zoom where titles and labels still show; and at least one edge
must be in view, so a camera that shows almost nothing cannot pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

mvalancy and others added 2 commits November 10, 2026 23:08
…thing

Small graphs (up to 60 items) are now framed whole down to 0.34, the zoom
where card titles and labels stop showing, instead of 0.38. When even that
is too small, the camera no longer centres on an empty middle. Organize
stays centred if the graph nearly fits (at least 0.8 of the floor);
otherwise it frames the hub's neighbourhood. The hierarchical layout shows
its top rows, prerequisites first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The layout-save notice appears after the camera has framed the graph, so a
card it covers is not off screen. It is now recorded as "partly under
controls" and is no longer a failure. The launch checklist runs on desktop
only: at the 0.34 cutoff a phone is about 1150 graph units wide, and the
hub with its seven neighbours does not fit with full labels. Compact label
chips, a follow-up, are what make it fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mvalancy mvalancy changed the title fix(graph): automatic layouts leave room for every relationship label fix(graph): relationship labels stay readable after Organize and in hierarchical Nov 11, 2026
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

mvalancy and others added 2 commits November 10, 2026 23:52
edgeRouteLabel now tries every spot along an edge, so a blocked label made
four times the checks it used to. Each placement first keeps the obstacles
and wires that reach any of its candidate spots (each one counts against the
check budget, so the budget still bounds the work), then tests each spot
against those with exact axis-aligned bounds before the turned-box test. The
turned-box test no longer allocates. Results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #280: Organize and the hierarchical toggle ran the search in the
click handler for up to 200 cards (1.4 s for 60 cards, 2.9 s for 200).

- Above WHOLE_FIT_MAX_CARDS (60) both layouts are the plain ones, framed at
  the 0.65 floor, whatever the floor (also for unplaced items).
- One click's search shares LAYOUT_CHECKS of label checks; a spread search
  that runs out keeps what it has.
- withFramedRoom finds the closest framed zoom from the layouts at 100% and
  at the lowest zoom plus at most FRAMED_STEPS more (regula falsi) instead of
  bisecting, and keeps looking above the lowest zoom when its layout does not
  fit there.
- Clearing labels after the force layout settles has the same bounds.
- Turning the hierarchical layout off restores the framing Organize chose.

Unit tests: 60 cards and 90 relationships within a second for each layout
(about 100 ms on a desktop), plain layouts above 60 cards at floor 0 and
0.65, and a four-spoke hub on a phone framed at its closest fitting zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mvalancy and others added 3 commits November 11, 2026 00:01
When labels still collide, Organize spread each component evenly. On a
phone that widened a hub's spokes past the screen: a hub with four spokes
on a 390 px phone framed at 0.39 (hub card 88 px, its title broken
mid-word). Each component now also tries spreading along the screen's long
side only, and keeps that when it frames closer for no more area: the same
hub frames at 0.49 in the unit geometry, and in the browser its card and
title read again (topology-organization at 390 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a phone

- organize-labels: each checked stage must draw every relationship of the
  graph (the edges-in-view check could pass with none drawn). It waits only
  for a Welcome graph to exist (CI had emptied it), and checks the launch
  checklist on iPhone too instead of skipping it (a blocking sequence may
  not skip): there the camera rests on the hub with no neighbour whole
  beside it until compact label chips (#283), so only that check is lifted.
- itemSymbolVisual: a turned label overlaps a card, control or label only
  when its outline does. Two parallel labels beside a diagonal wire have
  overlapping bounds while their outlines are 34 px apart (CI, item-wires).
- topology-organization: on a phone a small graph is framed whole down to
  the zoom where titles and labels still show, so the hub must read there
  (its title at 14 px, the camera at or above that zoom) instead of being
  132 px wide; desktop keeps 132 px. Titles must still match exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/ci.yml
#	packages/web/src/lib/README.md
#	tests/lib/runner/unified-cli.test.mjs
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

mvalancy and others added 5 commits November 11, 2026 01:05
On a 390 px phone two compact cards and a label side by side do not fit at
any zoom where the cards' titles still fit whole: below about 0.45 (in CI's
fonts) "System coordinator" broke to "coordinato r", and at 0.5 the hub card
was 131 px wide where topology-organization asks for 132. So a phone keeps
dev's Organize camera, the 0.65 floor centred on the busiest item's
neighbourhood, with label room for that zoom; the hierarchical layout keeps
the phone's 0.5 (PHONE_READABLE_SCALE, now in lib/cardTitleZoom.ts).
Desktops and tablets still frame a graph of up to 60 items whole down to
0.34. Fitting a whole graph on a phone needs compact label chips (#283).

topology-organization.spec.ts is back to dev's version. The organize-labels
journey checks for cards off screen against each stage's floor on iPhone,
and measures once the "Layout saved" note has faded (it covered the bottom
card for a few seconds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spreading a component along the screen's long side only was added so a hub
framed whole on a phone at 0.34 kept its title. Phones now keep the 0.65
Organize floor, and spreading one axis turns a hub's ring into an ellipse:
topology-organization at 1440 px found the spokes' distances from the hub
53 graph units apart. Components spread evenly again, as before that commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Real item ids are random and break ties in the organizer and the label
order. For some orders a label stayed hidden after the search said it fit:
the component spread was returned with a 1.1 slack nobody checked (spreading
is not monotonic: a wire can swing onto a label further out), and the
hierarchical rows were never checked after spreading both ways. The slack is
now kept only where it still frees every label, and each finished layout
(after packing and the quarter turn) is checked as a whole and spread a
little more if a label is still hidden.

A unit test lays out the launch checklist and two connected hubs with ids
from five seeds that hid a label before (5 of 10 cases failed), on three
screens, after Organize and in the hierarchical layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The renderer places each label starting from the spot it had in the
previous frame, so labels stay steady while cards move. After Organize the
camera animates to its zoom, and the spots picked on the way could box out
a label that a fresh placement (the one the layouts leave room for) shows:
the launch checklist on desktop lost one label in about half the journey
runs. When a graph has at most 60 relationships and a label is missing, the
renderer now also tries a fresh placement (dragged labels keep their spot)
and keeps it when it shows more labels. Larger graphs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both branches defined CARD_TITLES_HIDE_BELOW and CARD_TITLES_SHOW_ABOVE;
graphZoomContext now takes them from lib/cardTitleZoom.ts, so the zoom-out
levels and the relationship labels share one cutoff. The sequence lists,
profiles, CLI test and READMEs keep both organize-labels and zoom-out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

@mvalancy
mvalancy marked this pull request as ready for review November 11, 2026 09:39
mvalancy and others added 2 commits November 11, 2026 04:58
A compact card is at most 240 graph units wide and keeps its title at 14 px
on screen, so below about 0.45 zoom a long word no longer fits a line and
fitGraphText split it ("coordinato r", "Design mockup s"). Organize now rests
at 0.34 on a desktop, so the first press showed it. Titles now break only at
spaces; a word wider than the line is cut and ends in "…" ("coordinat…")
instead of carrying on to the next line, at every zoom. When more text
follows the last line, that line ends after a whole word and "…" where one
fits. Scripts written without spaces (Chinese, Japanese) still break between
characters.

A unit test wraps seven titles at widths 40 to 320 and fails if a line
starts or ends inside a word (it failed before: "Sys" / "tem"). The
organize-labels journey fails a stage when a card title line on screen is
not whole words of the title, or the start of one before "…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toolchain shard, the sequence list and profiles, the CLI test and the
READMEs keep both organize-labels and relationship-edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

@mvalancy

Copy link
Copy Markdown
Member Author

Independent review of 63c8f28 (whole PR against dev)

I ran the targeted unit suites and they all pass: labelClearance, graphSymbolLayout, topologyOrganization, layoutAlgorithms, edgeRouteGeometry, edgeLabelPlacement/Layout, graphFit, edgeGeometry, graphZoomContext, localItem*, organizationCamera. The numbers below come from throwaway probes run against the branch. Nothing was committed.

Freezes: bounded. Work is counted in checks, not time.

  • Organize on 60 cards with 90, 300 or 600 relationships takes about 60 to 120 ms in Node. Hierarchical takes about 35 to 95 ms. The settle-time clearFreeCards (two passes) takes about 20 ms.
  • computeCompactLayout on 2000 cards and 4000 edges takes 41 ms.
  • The renderer's fresh retry runs only on label passes (every 15th low-alpha tick, zoom end and settle), not on every zoom tick. It costs at most 2× ITEM_LABEL_BUDGET.

Determinism: OK. There is no Math.random and no timing in these paths. Ids break ties as on dev, and the organizer and rows sort by id, so input order does not matter. One caveat: the whole-layout check only holds while LAYOUT_CHECKS lasts. Dense 60-card graphs run out of it (Organize of 60 cards × 200 edges: 97 of 200 labels hidden in a whole-graph placement), and there the id order still decides.

must-fix

  1. clearFreeCards throws new cards far away (labelClearance.ts:683-703, InteractiveGraphVisualization.tsx:4154). Suppose any hidden label has both of its cards pinned, for example two saved cards too close together, which is the case this PR exists for. spreadForLabels(..., { moves: free }) then requires zero hidden labels over all edges. It can never get there, so it climbs to maxFactor and returns 4. Every free card moves to 4× its distance from the pinned centroid. The second pass does it again (16×), and persistAllPositions saves the result.

    • Probe: pinned A(0,0) and B(230,0), whose label is hidden; pinned C(0,400); free N(600,400), linked to C, with its own label already showing. After pass 1 N is at (2170,1200); after pass 2 it is at (8450,4400).
    • Real case: a saved graph plus one item an agent or the API added without a position.
    • Fix: judge a spread only on edges that have a free end, or on whether hidden labels decrease. Add a unit test for this mix of pinned and free cards.
  2. Hierarchical rows with any cross-row edge never wrap, at every graph size (layoutAlgorithms.ts:305). The PR says "Graphs over 60 items keep the plain layouts", but computeCompactLayout itself changed. The over-60 test (labelClearance.test.ts:251-257) compares against the new function, so it cannot catch this.

    • Probe, desktop, 220×80 cards, a hub with N dependents:

      Items dev PR
      20 988×800 4828×224
      100 988×3680 25308×224
      200 988×7280 50908×224

      Random 2000 cards × 4000 edges comes out 92124 units wide.

    • Fix: keep dev's wrapping above WHOLE_FIT_MAX_CARDS, and pin that test to dev's output. Below 60 this is a design call: a 20-item "all depend on one epic" graph was framed near 0.9 on dev and is now held at 0.34. If that is intended, say so in the PR body.

should-fix

  1. The first open of an unplaced graph is framed further out than on dev, often below the title cutoff (InteractiveGraphVisualization.tsx:2148, labelClearance.ts:430-432). The initial organization uses floor 0. When no label zoom fits, withFramedRoom returns the most spread layout, and the camera frames it below 0.34, where neither labels nor titles show.

    • Fit zoom, dev → PR, desktop unless noted:
      • Plan a launch: 0.370 → 0.201
      • Launch checklist: 0.343 → 0.262 (iPad 0.466 → 0.194)
      • Two connected hubs: 0.492 → 0.170
      • Hub with 12 spokes: 0.482 → 0.321
      • Long labels: 0.391 → 0.318
    • This hits imported or agent-created graphs (all items with no position).
    • Fix: on desktop and tablet, give it Organize's whole-fit floor and framing, or fall back to the plain organizer when nothing fits at floor 0.
  2. fitGraphText drops the rest of a long token at scale 1, even with lines free (graphSymbolLayout.ts:82). Ordinary titles do not change at scale 1: I checked 18, and the only change was titles that already overflowed 3 lines, which now end on a whole word. But a token wider than the line (a path, URL, identifier or compound word) used to wrap and show whole, and now loses its tail:

    • Update packages/web/src/lib/labelClearance.ts → ["Update","packages/web/src/lib/labelCle…"] (dev showed all of it on 3 lines)
    • PaymentGatewayRetryReconciliationHandler → one line, PaymentGatewayRetryReconci…
    • Investigate https://…/works…, then crash: an ellipsis in the middle of a title

    Suggestion: ellipsize only on compact cards (scale < 1), or break such tokens at / . - _ or camelCase while lines remain.

    The new "no split words" test does fail on dev's fitGraphText (System coordinator at 40: ['Sys','tem']), so it is not vacuous.

  3. The fresh label retry runs where it cannot help (InteractiveGraphVisualization.tsx:3798). It compares against every eligible candidate, but eligible ignores the viewport, and placeItemLabels stops at 32 labels. So with 33 to 60 relationships, or any edge off screen (any zoom-in), it runs on every label pass, never wins, and doubles the label work. Compare instead against in-viewport eligible candidates, capped at ITEM_LABEL_BUDGET.labels.

  4. clearFreeCards.hiddenNow acts on labels it never tried (labelClearance.ts:625-633). On the call that uses up the work, hidden includes labels skipped for budget, and step() pushes their cards apart. spreadForLabels and turnToScreen already treat that case as "no answer"; hiddenNow should do the same.

  5. Journey coverage gaps. The journey runs only on placed graphs, for Organize and hierarchical. It does not cover hierarchical → force, the unplaced first load (Complete Neo4j migration with DOM flash fixes and external access support #3) or the settle-time clearing (Complete startup automation and external access fixes #1). mode: 'unplaced' (organize-labels.spec.ts:46,58-65) is never used. The comment at unified.config.mjs:95 ("once the force layout settles … hard shapes") and the "force after / force (unplaced)" rows in the PR body describe stages the spec no longer has. The checks it does run are not vacuous.

Checked and fine

  • Camera framing: desktop/tablet frame whole at 0.34 for ≤60 cards; phones keep 0.65 after Organize and 0.5 in hierarchical.
  • Switching hierarchical back to force restores the earlier floor and framing.
  • Organize and hierarchical pin cards, and clearFreeCards moves only cards with fx == null. Saved positions are not overwritten, apart from Complete startup automation and external access fixes #1.

nits (simplicity)

  • Exported but used only inside their own module: LABEL_PADDING, labelWorldScale, labelSideOffset, spreadPoint, SPREAD_CHECKS, SPREAD_MAX_CARDS, FRAMED_STEPS (labelClearance.ts) and LABEL_SAMPLES (edgeRouteGeometry.ts). Likewise EXAMPLE_SHAPES, HARD_SHAPES and the *_SHAPE constants other than Welcome and Launch checklist (labelClearanceShapes.ts).
  • SPREAD_CHECKS (4M) is the default work for spreadForLabels/clearFreeCards. Every production caller passes LAYOUT_CHECKS, so the default is a trap: a future caller that omits it gets a budget more than 10× larger. Default to LAYOUT_CHECKS.
  • labelReport is test-only code in a production module.
  • dragged is passed through into the placeItemLabels candidates.

Verdict: not ready. Fix #1 and #2 first. The rest of the design is sound: the work is bounded, the results are deterministic and the retry is not per frame.

🤖 Generated with Claude Code

mvalancy and others added 6 commits November 11, 2026 05:52
After the force layout settles, clearFreeCards moved free cards for every
hidden label, including one between two saved cards that no free card can
fix: its spread search never fitted, returned its 4x maximum, and the
settled layout ran it twice, so a card an agent added to a saved graph flew
to 4x and then 16x its distance and was saved there.

Now only labels with a free card at one end count. The steps are kept only
when they show more of those labels; the spread moves only the free cards of
the groups those labels are in and is kept only when it shows every label of
the cards it moves. Run again on its own result, it moves nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The label-room rows had replaced computeCompactLayout itself, and a row that
edges leave never wraps there, so a graph too large to frame whole lost
dev's wrapping: a hub with 100 dependents became 25564 units wide instead of
988, and 2,000 items one very wide strip.

computeCompactLayout is dev's again, byte for byte, and graphs above
WHOLE_FIT_MAX_CARDS use it. The label-room rows are computeLabelRoomLayout,
used only up to 60 cards, where wrapping a row that edges leave would put
cards on those edges and hide their labels. Golden numbers taken from dev
pin the large layouts: a hub with 100 dependents is 988 x 3680, 2,000
items and 4,000 relationships 988 x 72656.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A graph whose items have no saved position is organized when it opens. That
organization had been given label room down to zoom 0 as well, which spread
it for the smallest labels and framed it further out than titles show:
"Plan a launch" opened at 0.20 instead of dev's 0.37 on a desktop. The
owner's report is about Organize and the layout toggle, so the first open
goes back to dev's organizer and framing.

The organize-labels journey now opens "Plan a launch" with every item
unplaced and checks it rests no lower than the 0.34 where titles and
labels show. The sequence comment says what the journey checks today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Breaking card titles only at spaces also cut every path, URL or identifier
wider than a line, even on a full-size card with lines to spare: "Update
packages/web/src/lib/labelClearance.ts" lost its file name.

Such a token (something other than letters inside its punctuation, or
camelCase) now breaks inside, after a separator or before a capital where
it can, and shows in full while lines are left; "…" only ends the last
line. An ordinary word wider than a line is still cut with "…", never
split. The journey's title check accepts pieces of such tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The renderer's fresh-spot retry ran whenever fewer labels were placed than
were drawn, which also counts labels off screen and past the 32-label cap:
it then ran on every label pass where it could not show anything more.
labelsMissed asks the placement itself: a label on screen was tried, found
no spot, and the cap leaves room for it.

The label-room lesson records the settled-layout rule, the large rows and
first open kept as on dev, and the narrower retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts: dev changed only the installer and its contract test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

The owner reported Organize and the force layout, not the hierarchical one.
Its label-room rows did not wrap a row that edges leave, so a common shape,
an epic with 20 tasks, went from 988 to 4828 units wide with the camera on
its top rows. The hierarchical layout is dev's again for every graph:
computeCompactLayout and its tests as on dev, the toggle's positions and
framing as on dev, and PHONE_READABLE_SCALE back beside the phone fit that
uses it. computeLabelRoomLayout, hierarchyWithLabelRoom, the rows-only
spread and their tests are gone, and the organize-labels journey checks
Organize and the first open of an unplaced graph only. Label room in the
hierarchical layout is #283.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 22.18.0 & 24.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

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