diff --git a/CLAUDE.md b/CLAUDE.md index 965566ae3..006e18a0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) -**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest. +**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest. **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2409dac6f..4967662a6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -84,7 +84,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart. -**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and same-row pairs get a shallow U-bridge HANGING BELOW the strip (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting), while a wrapped strip (`tabs-two-rows`/`tabs-auto-wrap`) falls back to the vertical bezier. +**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1. ⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. diff --git a/docs/session-lineage-lines-plan.md b/docs/session-lineage-lines-plan.md index f77aa2c56..bb5c6950b 100644 --- a/docs/session-lineage-lines-plan.md +++ b/docs/session-lineage-lines-plan.md @@ -93,17 +93,37 @@ The path math itself lives in `constants.js` as a pure ### 4.2 Geometry Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom → -window-top) does not apply. Two cases: +window-top) does not apply. **One case**, a **U-bridge hanging below the strip** that +touches both tabs on their bottom edge: -- **Same row** (the normal case): a shallow **U-bridge hanging below the strip**. - `y0 = max(parent.bottom, child.bottom)`, dip - `d = clamp(14 + |x2 - x1| * 0.06, 16, 44) + depth * 6`, path - `M x1 y0 C x1 y0+d, x2 y0+d, x2 y0`. `depth` is the child's index among its - siblings, so several children of one parent **nest** instead of overprinting. -- **Different rows** (`tabs-two-rows` / `tabs-auto-wrap` on desktop): the existing - vertical bezier from parent-bottom-center to child-top-center. +``` +y0 = max(parent.bottom, child.bottom) +d = clamp(14 + |x2 - x1| * 0.085, 22, 104) + depth * 8 + |child.bottom - parent.bottom| +path: M x1 parent.bottom C x1 y0+d, x2 y0+d, x2 child.bottom +``` -A small `` at the child end marks direction (an SVG `marker` would need a +`depth` is the child's index among its siblings, so several children of one parent +**nest** instead of overprinting. + +> **Superseded (2026-08-14): the two shapes this section used to specify.** The dip was +> `clamp(14 + span * 0.06, 16, 44) + depth * 6`, and a wrapped strip +> (`tabs-two-rows` / `tabs-auto-wrap`) got its own parent-bottom → child-**top** bezier. +> Both were tuned against two tabs side by side and failed at the distances the feature +> is used at: +> +> - a skill worker is appended to the **end** of the strip, so the real span is +> 800-1500px, where a 44px cap is a 33px sag, i.e. a line that reads as straight and +> crosses the terminal instead of bracketing under the strip; +> - and when the strip wraps, parent-bottom (34) to child-top (48) leaves **14px** to +> bend in, so the arc was a flat line hidden in the row gap, with siblings drawn on +> top of each other. Reported as *"they connect already, but the lines are straight +> and not easy visible"*. +> +> Anchoring both ends at the tab bottoms and hanging the control points below the +> **lower** row gives the wrapped case the same bracket as the flat one, and removes the +> branch. Pinned by `test/session-lineage-lines.test.ts`. + +A small `` at the child end marks direction (it breathes to 4.5 while that worker is busy) (an SVG `marker` would need a `` block and fights `stroke-dasharray`). Each path gets `class="connection-line lineage-line"`, `data-parent-tab`, @@ -139,8 +159,12 @@ callers are cheap. Needed: ### 4.5 Styling `.connection-line.lineage-line`: violet stroke from a `--lineage-line` token, -`stroke-width: 2`, `dasharray 4 4`, `opacity: .55`, softer glow than the subagent lines -so the two layers read as different things. Trap to respect: the skin block nests under +`stroke-width: 2.5`, `dasharray 5 5`, `opacity: .72` (`.95` while the child works), +softer than the subagent lines so the two layers read as different things, but the +contrast comes from a **second, wider glow** rather than more weight, because the first +cut (2px / `4 4` / `.55` / one 5px glow) disappeared into terminal text on a real 1080p +desktop. `lineage-flow` marches by two dash cycles, so it moves with the dash array +(`5 5` → `-20`). Trap to respect: the skin block nests under `html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the base rule at higher specificity. **Define the color as a token per skin, keep exactly one `.lineage-line` rule.** Light skins get a darker stroke. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1d206fd13..e7a375596 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -201,24 +201,43 @@ function computeTabScrollLeft(input) { // spawned (a worker started through the codeman agent skill, which passes its own // id as parentSessionId). Pure: the caller measures and appends, this decides. // -// Two shapes, because both endpoints live in ONE horizontal strip and the subagent -// shape (tab-bottom → window-top) has nothing to aim at: -// - same row: a shallow U-bridge HANGING BELOW the strip, so it reads as a -// bracket joining two tabs rather than as a line crossing them. The dip grows -// with horizontal distance and with `depth` (the child's index among its -// siblings), so several children of one parent nest instead of overprinting. -// - different rows (desktop `tabs-two-rows` / `tabs-auto-wrap`): the vertical -// bezier the subagent lines already use, parent edge → child edge. +// ONE shape, because both endpoints live in the same horizontal strip and the subagent +// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the +// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a +// bracket joining two tabs rather than as a line crossing them. The dip grows with +// horizontal distance and with `depth` (the child's index among its siblings), so +// several children of one parent nest instead of overprinting. +// +// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the +// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its +// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier +// had a 4px span to work with and drew a flat horizontal line inside the row gap +// (reported as "they connect already, but the lines are straight and not easy visible"), +// and three siblings drew three of them on top of each other. Aiming BOTH ends at the +// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case +// the same bracket as the flat case: it leaves the parent downward, crosses the lower +// row once, and comes back up under the child. Same formula, no branch. // // Returns null when the edge must not be drawn: a missing/degenerate rect, or an // endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a // scrolled-out tab still HAS a rect — one lying over the logo or the header // buttons. Skipping is honest; clamping would point at a tab that isn't there. +// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned +// against two tabs sitting side by side. A worker the agent skill starts is appended +// to the END of the strip, so the real span between a lead and its worker is 800-1500px, +// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as +// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now +// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket +// survives the distance the feature is actually used at. The ceiling is what keeps a +// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the +// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get. const LINEAGE_DIP_BASE_PX = 14; -const LINEAGE_DIP_PER_PX = 0.06; -const LINEAGE_DIP_MIN_PX = 16; -const LINEAGE_DIP_MAX_PX = 44; -const LINEAGE_SIBLING_STEP_PX = 6; +const LINEAGE_DIP_PER_PX = 0.085; +const LINEAGE_DIP_MIN_PX = 22; +const LINEAGE_DIP_MAX_PX = 104; +// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px +// apart bled into one thick band instead of reading as three separate lines. +const LINEAGE_SIBLING_STEP_PX = 8; const LINEAGE_STRIP_TOLERANCE_PX = 4; function computeLineagePath(input) { @@ -250,29 +269,20 @@ function computeLineagePath(input) { const cBottom = cTop + ch; const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2; - let d; - let endX; - let endY; - if (sameRow) { - const y0 = Math.max(pBottom, cBottom); - const span = Math.abs(cx - px); - const dip = - Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) + - depth * LINEAGE_SIBLING_STEP_PX; - const yc = y0 + dip; - d = `M ${r1(px)} ${r1(y0)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(y0)}`; - endX = cx; - endY = y0; - } else { - const childBelow = cTop + ch / 2 > pTop + ph / 2; - const y1 = childBelow ? pBottom : pTop; - const y2 = childBelow ? cTop : cBottom; - const mid = (y1 + y2) / 2; - d = `M ${r1(px)} ${r1(y1)} C ${r1(px)} ${r1(mid)}, ${r1(cx)} ${r1(mid)}, ${r1(cx)} ${r1(y2)}`; - endX = cx; - endY = y2; - } - return { d, endX, endY, sameRow }; + // Both ends anchor on the tab BOTTOM, and the control points hang below whichever + // row is lower, so one formula covers a flat strip and a wrapped one. + const span = Math.abs(cx - px); + const rowDrop = Math.abs(cBottom - pBottom); + // ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would + // only reach the row gap again. Adding the row offset also keeps the curve clear of + // the row it crosses instead of grazing its bottom edge. + const dip = + Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) + + depth * LINEAGE_SIBLING_STEP_PX + + rowDrop; + const yc = Math.max(pBottom, cBottom) + dip; + const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`; + return { d, endX: cx, endY: cBottom, sameRow }; } // One decimal is plenty for a screen-space path and keeps the `d` string short. diff --git a/src/web/public/session-lineage.js b/src/web/public/session-lineage.js index ace40d6d4..ecab6c5d0 100644 --- a/src/web/public/session-lineage.js +++ b/src/web/public/session-lineage.js @@ -148,7 +148,9 @@ Object.assign(CodemanApp.prototype, { const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); dot.setAttribute('cx', String(geom.endX)); dot.setAttribute('cy', String(geom.endY)); - dot.setAttribute('r', '3'); + // Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child + // works, so the two have to be changed together. + dot.setAttribute('r', '3.5'); dot.setAttribute('class', 'lineage-line-dot' + working); dot.setAttribute('data-child-tab', edge.childId); svg.appendChild(dot); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 50bc975a1..f41146f33 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9257,31 +9257,46 @@ kbd { light skins included). Do not add a per-skin `.lineage-line` override inside the html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1) and would outrank this one from a surprising place. */ +/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55 + with a single 5px glow, which reads on a design mock and disappears on a real + 1080p desktop: a faint thread over terminal text, exactly what it is drawn on + top of. The weight stays UNDER the subagent lines' 3px so the two layers still + separate, and the second, wider glow is what buys the contrast instead: it lifts + the line off the terminal without thickening it. Dashes scale with the stroke + (4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by + exactly two dash cycles, so it has to move with them. */ .connection-line.lineage-line { stroke: var(--session-purple, #a98fe0); - stroke-width: 2; - stroke-dasharray: 4 4; + stroke-width: 2.5; + stroke-dasharray: 5 5; stroke-linecap: round; - opacity: 0.55; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 5px var(--session-purple, #a98fe0)); + opacity: 0.72; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-purple, #a98fe0)) + drop-shadow(0 0 11px var(--session-purple, #a98fe0)); +} + +/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case + the line exists to signal, and pairing the brightness with the marching dashes + left every worker's arc at the resting 0.72 for anyone who turns motion off. */ +.connection-line.lineage-line--working { + opacity: 0.95; } .connection-line.lineage-line:hover { - opacity: 0.9; - stroke-width: 2.5; + opacity: 1; + stroke-width: 3; } .lineage-line-dot { fill: var(--session-purple, #a98fe0); - opacity: 0.7; - filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0)); + opacity: 0.85; + filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0)) drop-shadow(0 0 9px var(--session-purple, #a98fe0)); } /* The child end marches while that worker is actually working, so the line itself carries the signal. Motion is opt-out-able at the OS level. */ @media (prefers-reduced-motion: no-preference) { .connection-line.lineage-line--working { - opacity: 0.85; animation: lineage-flow 1.1s linear infinite; } @@ -9291,20 +9306,24 @@ kbd { } } +/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray` + above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that + matched the old `4 4`. Leaving them out of step makes the dashes jump once per + iteration. */ @keyframes lineage-flow { to { - stroke-dashoffset: -16; + stroke-dashoffset: -20; } } @keyframes lineage-dot-pulse { 0%, 100% { - opacity: 0.6; - r: 3; + opacity: 0.75; + r: 3.5; } 50% { opacity: 1; - r: 4; + r: 4.5; } } diff --git a/test/session-lineage-lines.test.ts b/test/session-lineage-lines.test.ts index 094dcf058..5f5df87d1 100644 --- a/test/session-lineage-lines.test.ts +++ b/test/session-lineage-lines.test.ts @@ -77,25 +77,47 @@ describe('lineage line geometry', () => { expect(first.d).not.toBe(second.d); }); - it('switches to a vertical bezier when the strip has wrapped to two rows', () => { + it('keeps bending at strip-wide spans instead of flattening into a straight line', () => { const helper = loadLineageHelper(); + // A worker the agent skill starts is appended to the END of the strip, so this + // is the span the feature is actually used at. The first shipped clamp (44px) + // turned it into a flat thread across the terminal. + const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!; + const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!; + + const wideDip = controlYs(wide.d)[0] - 34; + const nearDip = controlYs(near.d)[0] - 34; + expect(wideDip).toBeGreaterThan(nearDip * 2); + expect(wideDip).toBeGreaterThanOrEqual(80); + }); + + it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => { + const helper = loadLineageHelper(); + // The reported bug: with the desktop strip wrapped, a parent on row 1 (bottom 34) + // and its child on row 2 (top 48) are 14px apart, and a parent-bottom → child-TOP + // bezier had 14px to bend in, so it drew a flat line hidden in the gap, three + // siblings overprinting each other. Both ends now anchor on the tab BOTTOM and the + // curve hangs below the LOWER row, the same bracket the flat strip gets. const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 }; const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!; expect(geom.sameRow).toBe(false); - // Parent bottom (34) → child top (48): the arc travels between rows. - expect(geom.d.startsWith('M 60 34')).toBe(true); - expect(geom.endY).toBe(48); + expect(geom.d.startsWith('M 60 34')).toBe(true); // parent BOTTOM + expect(geom.endY).toBe(78); // child BOTTOM, not its top + // Every control point clears the lower row by at least the minimum dip. + for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX); }); - it('draws upward when the child sits on the row ABOVE its parent', () => { + it('draws the same bracket when the child sits on the row ABOVE its parent', () => { const helper = loadLineageHelper(); const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 }; const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!; expect(geom.sameRow).toBe(false); - expect(geom.d.startsWith('M 60 48')).toBe(true); // parent TOP edge - expect(geom.endY).toBe(34); // child bottom edge + expect(geom.d.startsWith('M 60 78')).toBe(true); // parent BOTTOM + expect(geom.endY).toBe(34); // child BOTTOM + // The parent's row is the lower one here, so that is what the curve clears. + for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX); }); it('skips an edge whose tab is scrolled out of the strip', () => {