diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6f0e49a7..087944fc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -316,6 +316,11 @@ function computeTabScrollLeft(input) { // (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last // row's gap, and the spine channel on the left of a wrapped strip). // +// The channel is at the strip's left edge, except where a tab arrangement puts +// something there: grouped by state, the edge holds the label column and the +// channel opens between the labels and the tabs. session-lineage.js measures it +// and passes its left edge as `spineLeft`; without one it is the strip's edge. +// // Rows come from computeLineageRows() over EVERY tab in the strip, not only the // endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px // taller), or siblings in one row would hang their bus at different heights. @@ -440,9 +445,11 @@ function lineagePolylinePath(points, radius) { /** * Routes from one parent tab to each of its children. * - * input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?, - * laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are - * derived from it); `lane`/`laneCount` separate families drawn together. + * input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?, + * orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in + * the strip (rows are derived from it); `spineLeft` is the left edge of the + * spine channel (default: the strip's left edge); `lane`/`laneCount` + * separate families drawn together. * Returns { routes: [{ id, points, d, endX, endY }] } or null. */ function computeLineageTree(input) { @@ -509,9 +516,19 @@ function computeLineageTree(input) { }; const pRow = rowOf(parent); const gp = gapUnder(pRow); - const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left); - const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts); - const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2; + // The spine runs from one row's gap to another's, so it only ever passes BESIDE + // rows after the first, and only their tabs bound it. The first row may start + // left of the channel (grouped by state it starts after the brand and a label + // of its own width), and clamping to it would pull the spine back over the + // label column. + const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)] + .filter((r) => r && rowOf(r) > 0) + .map((r) => r.left); + const minLeft = lowerLefts.length ? Math.min(...lowerLefts) : Math.min(parent.left, ...visible.map((c) => c.rect.left)); + const channelLeft = Number(input?.spineLeft); + const spineBase = strip + ? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left) + : minLeft - LINEAGE_SPINE_INSET_PX * 2; const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2); for (const { id, rect } of visible) { diff --git a/src/web/public/session-lineage.js b/src/web/public/session-lineage.js index c7f0fb21..0bec73f6 100644 --- a/src/web/public/session-lineage.js +++ b/src/web/public/session-lineage.js @@ -260,7 +260,19 @@ Object.assign(CodemanApp.prototype, { // The header strip's rows come from EVERY tab in it (computeTree hangs a row's // gap under its tallest tab), web tabs included. The rail needs none. const tabRects = []; + let spineLeft; if (orientation === 'horizontal') { + // Where the spine channel is: the reserved --lineage-spine-channel just left + // of the strip's content edge. Usually that is the strip's own left edge, + // but grouped by state the label column comes first (styles.css), and a + // spine at the edge ran through every label. Read back from the padding + // the CSS laid out, so geometry and stylesheet cannot disagree. + const style = typeof getComputedStyle === 'function' ? getComputedStyle(strip) : null; + const channel = style ? parseFloat(style.getPropertyValue('--lineage-spine-channel')) : NaN; + if (Number.isFinite(channel)) { + const inset = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0); + spineLeft = stripRect.left + inset - channel; + } for (const tab of strip.querySelectorAll('.session-tab')) { const id = tab.getAttribute('data-id'); const key = id ? 'tab:' + id : null; @@ -292,6 +304,7 @@ Object.assign(CodemanApp.prototype, { children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })), strip: stripRect, tabs: tabRects, + spineLeft, orientation, lane, laneCount, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 28c120c3..753f7ce6 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -20734,7 +20734,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] { line's cross size is clamped at zero. A margin on the items spaces a group boundary and a group's own continuation line alike, and the break, which carries none, adds nothing. The last row's margin replaces the bottom - padding (with lineage, part of its room). The gap is --lineage-row-gap + padding (with lineage, part of its room, below). The gap is --lineage-row-gap while lineage lines are routed through it (`.session-tabs.lineage-tree`, near the top of this file). */ .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) { @@ -20747,10 +20747,6 @@ html .toolbar .btn-git-status[aria-expanded='true'] { margin-bottom: var(--lineage-row-gap, 4px); } -.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) { - padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap)); -} - .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head { justify-content: flex-start; width: var(--tab-triage-gutter, 92px); @@ -20771,6 +20767,31 @@ html .toolbar .btn-git-status[aria-expanded='true'] { height: 0; } +/* Lineage lines (session-lineage.js): the spine channel (the 20px the lineage + rule reserves on the left of a wrapped strip) opens BETWEEN the label column + and the tabs, not at the strip's edge. At the edge it was the label column + itself: the gutter padding replaced the channel and the spine ran through + every label it passed. session-lineage.js reads the channel's position back + from this padding (the content edge minus --lineage-spine-channel), so the + two cannot drift apart. Labels keep their column under the brand, and the + lead row, which the spine never runs alongside, still starts right after + the brand. */ +.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) { + padding-left: calc(var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel)); + /* The last row's margin is part of the bottom routing room. */ + padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap)); +} + +.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head { + margin-left: calc(-1 * (var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel))); + margin-right: var(--lineage-spine-channel); +} + +.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead { + margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px) - var(--lineage-spine-channel)); + margin-right: 0; +} + /* Side rail and sidebar: each heading is a section row above its rows. */ html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-triage > .tab-triage-head, html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-triage > .tab-triage-head { diff --git a/test/session-lineage-lines.test.ts b/test/session-lineage-lines.test.ts index 616245d3..ce69542a 100644 --- a/test/session-lineage-lines.test.ts +++ b/test/session-lineage-lines.test.ts @@ -23,6 +23,7 @@ type TreeInput = { children: Array<{ id: string; rect: Rect | null }>; strip?: Rect; tabs?: Rect[]; + spineLeft?: number; orientation?: 'horizontal' | 'vertical'; lane?: number; laneCount?: number; @@ -81,6 +82,41 @@ function wrappedStrip() { return { strip, rows, all: rows.flat() }; } +/** + * The header strip grouped by state (tabArrangement 'state', the default): a label + * column 85px wide at the strip's left edge, then the 20px spine channel, then the + * tabs; rows 42px apart. The first row starts beside the brand (83px) with its own + * label, so its first tab sits at `leadLeft` from the strip's edge. + */ +function stateRowsStrip({ leadLeft = 160 } = {}) { + const strip: Rect = { left: 12, top: 6, width: 1300, height: 0 }; + const gutter = 85; + const channel = 20; + const labels: Rect[] = []; + const rows: Rect[][] = []; + let top = 8; + for (const [i, widths] of [ + [120, 110, 130], + [150, 120, 140, 110], + [140, 120, 200], + [130, 120], + ].entries()) { + let left = i === 0 ? strip.left + leadLeft : strip.left + gutter + channel; + // A label is a line of text inside its row: lead after the brand, the rest in + // the column, each narrower than the column (its widest label + 10px). + labels.push({ left: i === 0 ? strip.left + 83 : strip.left, top: top + 10, width: i === 0 ? 70 : 60, height: 10 }); + const cells: Rect[] = []; + for (const w of widths) { + cells.push(tab(left, top, w)); + left += w + 2; + } + rows.push(cells); + top += 30 + 12; + } + strip.height = top - 6 + 2; + return { strip, gutter, channel, labels, rows, all: rows.flat() }; +} + /** Does an axis-aligned segment pass through the INSIDE of a rect (touching an edge is fine)? */ function crossesRect([x1, y1]: Point, [x2, y2]: Point, r: Rect): boolean { const eps = 0.5; @@ -285,6 +321,56 @@ describe('lineage tree geometry: header strip', () => { expect(geom.routes.map((r) => r.id)).toEqual(['ok']); }); + it('runs the spine between the state labels and the tabs, never through a label', () => { + // tabArrangement 'state': a label column at the strip's left edge, the spine + // channel after it, the tabs after that. The first row starts beside the + // brand with a label of its own width. The spine used to sit at the strip's + // edge, which grouped by state is the label column. + const helper = loadLineageHelper(); + const { strip, gutter, channel, labels, rows, all } = stateRowsStrip(); + const parent = rows[2][2]; + const children = [rows[0][1], rows[1][0], rows[1][2], rows[3][0], rows[3][1]].map((rect, i) => ({ + id: `c${i}`, + rect, + })); + const geom = helper.computeTree({ parent, children, strip, tabs: all, spineLeft: strip.left + gutter })!; + + expect(geom.routes).toHaveLength(children.length); + const spineX = geom.routes.find((r) => r.id === 'c3')!.points[2][0]; + expect(spineX).toBe(strip.left + gutter + helper.SPINE_INSET_PX); + // Right of the label column (the first row's label, beside the brand, is never + // passed: the spine only runs beside the rows under it), left of the tabs. + expect(spineX).toBeGreaterThan(Math.max(...labels.slice(1).map((r) => r.left + r.width))); + expect(spineX).toBeLessThan(strip.left + gutter + channel); + for (const route of geom.routes) { + for (let i = 1; i < route.points.length; i++) { + for (const r of [...all, ...labels]) { + expect(crossesRect(route.points[i - 1], route.points[i], r), `${route.id} segment ${i}`).toBe(false); + } + } + } + }); + + it('keeps the spine in its channel when the first row starts left of it', () => { + // A quiet first group (idle, no label) puts the first row's first tab just + // after the brand, left of the channel. The spine never runs beside the first + // row, so that tab must not drag it back over the label column. + const helper = loadLineageHelper(); + const { strip, gutter, labels, rows, all } = stateRowsStrip({ leadLeft: 80 }); + expect(rows[0][0].left).toBeLessThan(strip.left + gutter + helper.SPINE_INSET_PX); + const geom = helper.computeTree({ + parent: rows[0][1], + children: [{ id: 'low', rect: rows[3][1] }], + strip, + tabs: all, + spineLeft: strip.left + gutter, + })!; + const spineX = geom.routes[0].points[2][0]; + + expect(spineX).toBe(strip.left + gutter + helper.SPINE_INSET_PX); + expect(spineX).toBeGreaterThan(Math.max(...labels.slice(1).map((r) => r.left + r.width))); + }); + it('still draws when no strip rect is supplied (clipping is opt-in)', () => { const helper = loadLineageHelper(); const geom = helper.computeTree({ parent: tab(0), children: [{ id: 'far', rect: tab(9000, 46) }] })!; @@ -465,10 +551,17 @@ function fakeNode(tag: string): FakeNode { * Load session-lineage.js for real, on a one-row strip where every session is a * 120px tab, 140px apart. The colour memo is plain state on the app instance. */ -function loadLineageApp(sessions: Record, status: Record = {}) { +function loadLineageApp( + sessions: Record, + status: Record = {}, + layout: { rect?: (i: number) => Rect; style?: Record } = {} +) { function CodemanApp(this: unknown) {} const ids = Object.keys(sessions); - const rect = (i: number) => ({ left: i * 140, top: 4, width: 120, height: 30, right: i * 140 + 120, bottom: 34 }); + const rect = (i: number) => { + const r = layout.rect ? layout.rect(i) : { left: i * 140, top: 4, width: 120, height: 30 }; + return { ...r, right: r.left + r.width, bottom: r.top + r.height }; + }; const stripClasses = new Set(); const listeners: Record void> = {}; const strip = { @@ -491,6 +584,15 @@ function loadLineageApp(sessions: Record, status: Record< CodemanApp, MobileDetection: { getDeviceType: () => 'desktop' }, CSS: { escape: (v: string) => v }, + // The strip's computed style, when a test lays one out (the spine channel). + ...(layout.style + ? { + getComputedStyle: () => ({ + ...layout.style, + getPropertyValue: (name: string) => layout.style![name] ?? '', + }), + } + : {}), document: { documentElement: { getAttribute: () => 'horizontal' }, getElementById: (id: string) => (id === 'sessionTabs' ? strip : null), @@ -636,6 +738,31 @@ describe('lineage routing room', () => { }); }); +describe('lineage spine channel, read back from the laid-out strip', () => { + // Two rows inside the fake 50px strip: w1 and a on the first, b on the second, + // so b's route takes the spine. + const rows = (i: number): Rect => (i < 2 ? tab(200 + i * 140, 2, 120, 14) : tab(125, 28, 120, 14)); + const spineOf = (style?: Record) => { + const { app } = loadLineageApp({ w1: null, a: 'w1', b: 'w1' }, {}, { rect: rows, style }); + const families = draw(app, 'w1'); + const d = family(families, 'w1').paths.find((p) => p.attrs['data-child-tab'] === 'b')!.attrs.d; + // The spine is the one x the route visits left of every tab. + return Math.min(...[...d.matchAll(/[MLQ] (-?[\d.]+)/g)].map((m) => Number(m[1]))); + }; + + it('opens the channel just left of the content edge the CSS laid out', () => { + // Grouped by state: an 85px label column, then the 20px channel. + const spine = spineOf({ '--lineage-spine-channel': ' 20px', paddingLeft: '105px', borderLeftWidth: '0px' }); + expect(spine).toBe(85 + 6); + }); + + it('keeps the strip edge when the channel is the padding itself, or not reserved', () => { + expect(spineOf({ '--lineage-spine-channel': '20px', paddingLeft: '20px', borderLeftWidth: '0px' })).toBe(6); + expect(spineOf({ '--lineage-spine-channel': '', paddingLeft: '4px', borderLeftWidth: '0px' })).toBe(6); + expect(spineOf()).toBe(6); + }); +}); + describe('lineage redraw after a selection resizes tabs', () => { it('redraws when a size transition in the strip ends, and only for size properties', () => { // The active tab reveals its icons by transitioning padding, so it keeps diff --git a/test/tab-triage.test.ts b/test/tab-triage.test.ts index b8df2d29..34989f5f 100644 --- a/test/tab-triage.test.ts +++ b/test/tab-triage.test.ts @@ -468,6 +468,36 @@ describe('tab grouping wiring (static)', () => { ); }); + it('opens the lineage spine channel between the label column and the tabs', () => { + // The gutter padding used to replace the lineage channel, so the spine ran + // through every label. With lineage lines, the strip pads by both, every label + // keeps its column at the edge and gives the channel back after itself, and + // the first row's label still starts right after the brand. + const lineage = '.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows)'; + const rule = (selector: string) => { + const start = css.indexOf(`${selector} {`); + expect(start, selector).toBeGreaterThan(-1); + return css.slice(start, css.indexOf('}', start)); + }; + expect(rule(lineage)).toContain( + 'padding-left: calc(var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel));' + ); + const head = rule(`${lineage} > .tab-triage-head`); + expect(head).toContain('margin-left: calc(-1 * (var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel)));'); + expect(head).toContain('margin-right: var(--lineage-spine-channel);'); + const lead = rule(`${lineage} > .tab-triage-head--lead`); + expect(lead).toContain( + 'margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px) - var(--lineage-spine-channel));' + ); + expect(lead).toContain('margin-right: 0;'); + // The lead rule must come after the head rule it overrides (equal specificity). + expect(css.indexOf(`${lineage} > .tab-triage-head--lead {`)).toBeGreaterThan( + css.indexOf(`${lineage} > .tab-triage-head {`) + ); + // session-lineage.js reads the channel's position back from that padding. + expect(read('session-lineage.js')).toContain("getPropertyValue('--lineage-spine-channel')"); + }); + it('lets the rows after the first start under the brand, labels left-aligned', () => { expect(css).toMatch( /\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/