dashboard: connect tree-prefix vertical bars across taller rows (#388)

The container-row tree-prefix used text box-drawing glyphs (├ └ │)
positioned with `top: 0.6em` — a single text-line tall. Once rows
grew past one line (5em square icon + multi-line body), the `│`
columns of consecutive siblings no longer touched, leaving visible
breaks in the tree.

Replace the text-glyph string with structured DOM: one `.tree-lane`
per depth column. Continuation lanes (`.lane-line`) paint a 1px
border-left spanning the full row height + the `.containers` gap
below, so adjacent siblings' bars visually merge into one unbroken
vertical. The row's own joint lane is `├` (branch — bar continues
below) or `└` (last — bar stops at icon midline), with a horizontal
stub at 3.1em (row padding-top + icon half-height) reaching to the
icon edge.

Joint y / stub width are derived from the 5em icon + 0.6em row
padding-top + 0.8em row padding-left so they meet the icon cleanly.
This commit is contained in:
iris 2026-05-25 00:32:15 +02:00 committed by Mara
parent 47403595f1
commit 7743c07380
2 changed files with 89 additions and 20 deletions

View file

@ -649,16 +649,29 @@ window.marked = marked;
} }
return out; return out;
} }
function treePrefix({ depth, ancestorIsLast, isLast }) { // Builds the .tree-prefix DOM for a row at the given depth. Each
if (depth === 0) return ''; // lane is its own positioned child so CSS can paint full-height
let s = ''; // vertical bars that bridge the gap between sibling rows — text
// Ancestor at depth 0 (root) doesn't get a vertical-line column — // box-drawing glyphs only paint one text-line tall, which left
// roots are separated visually as top-level rows already. // visible breaks between rows once we grew taller-than-one-line
// container cards (#388). Ancestor lanes are either continuation
// (vertical bar top→bottom+gap) or blank; the joint at this row's
// own depth is ├ (branch — vertical continues below) or └ (last —
// vertical stops at the row's icon midline). CSS at
// `.container-row .tree-prefix` paints the bars + horizontal stub.
function treePrefixDom({ depth, ancestorIsLast, isLast }) {
if (depth === 0) return null;
const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
// Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
// continuation column — top-level rows are separated visually as
// top-level rows already).
for (let d = 1; d < depth; d++) { for (let d = 1; d < depth; d++) {
s += ancestorIsLast[d] ? ' ' : '│ '; const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
prefix.append(el('span', { class: cls }));
} }
s += isLast ? '└─ ' : '├─ '; const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
return s; prefix.append(el('span', { class: jointCls }));
return prefix;
} }
function renderContainers(s) { function renderContainers(s) {
@ -731,10 +744,8 @@ window.marked = marked;
// legacy flat layout (every container at depth 0) is bit- // legacy flat layout (every container at depth 0) is bit-
// identical to today's render — no glyph, no indent. // identical to today's render — no glyph, no indent.
if (node.depth > 0) li.dataset.depth = String(node.depth); if (node.depth > 0) li.dataset.depth = String(node.depth);
const prefix = treePrefix(node); const prefix = treePrefixDom(node);
if (prefix) { if (prefix) li.prepend(prefix);
li.prepend(el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }, prefix));
}
// Full-height square agent icon, left of the card body. The // Full-height square agent icon, left of the card body. The
// icon is an <img> absolutely positioned inside a wrapper div: // icon is an <img> absolutely positioned inside a wrapper div:

View file

@ -204,18 +204,76 @@ a:hover {
.container-row[data-depth="4"] { margin-left: 7.2em; } .container-row[data-depth="4"] { margin-left: 7.2em; }
.container-row[data-depth="5"] { margin-left: 9em; } .container-row[data-depth="5"] { margin-left: 9em; }
.container-row[data-depth="6"] { margin-left: 10.8em; } .container-row[data-depth="6"] { margin-left: 10.8em; }
/* Tree prefix sits in the left margin and paints the connecting
/ / lanes as CSS rules rather than text glyphs (#388). Each
ancestor depth gets its own `.tree-lane` so we can paint a
full-row-height vertical bar that extends through the
.containers row gap into the next sibling text box-drawing
glyphs only fill one text line, which left visible breaks
between rows once cards grew taller than one line of text (5em
square icons + multi-line body). The horizontal stub at the row's
own joint lands at the icon midline so the L/T meets the icon
edge cleanly. */
.container-row .tree-prefix { .container-row .tree-prefix {
/* Tree-line glyphs sit in the left margin so the rest of the
container card layout (icon + body flex split) stays unchanged.
Mono font for column alignment with the / / bricks. */
position: absolute; position: absolute;
left: -1.6em; /* Extend into the `.containers { gap: 0.4em }` below so vertical
top: 0.6em; bars connect to the next sibling's prefix without a visual gap. */
color: var(--purple-dim); top: 0;
font-family: inherit; bottom: -0.4em;
white-space: pre; display: flex;
flex-direction: row;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
color: var(--purple-dim);
}
/* Each depth step is one 1.8em lane wide same step as the row's
own margin-left ladder above, so the rightmost lane (the joint)
sits flush against the row content (the icon). The prefix's left
edge is depth*1.8em LEFT of the row's left edge, so its right
edge meets the icon, and the leftmost lane lines up with the
top-level rows' icons at x=0. */
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; }
.container-row[data-depth="5"] .tree-prefix { left: -9em; }
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; }
.tree-prefix .tree-lane {
flex: 0 0 1.8em;
position: relative;
}
/* Continuation bar full row + gap below (so two adjacent
ancestor-line lanes from sibling rows visually merge into one
unbroken vertical line). Drawn at lane center (0.6em from left)
to keep it visually centered in the 1.8em column. */
.tree-prefix .lane-line::before,
.tree-prefix .lane-joint::before {
content: '';
position: absolute;
left: 0.6em;
top: 0;
bottom: 0;
border-left: 1px solid currentColor;
}
/* Last-child joint (): vertical bar stops at the icon midline (no
continuation below this row). 3.1em row padding-top 0.6em +
icon half-height 2.5em, matching the horizontal stub's y below. */
.tree-prefix .lane-joint-last::before {
bottom: auto;
height: 3.1em;
}
/* Horizontal stub from the joint's vertical bar across the lane
and through the row's padding-left to the icon left edge. Lands
at the icon midline so the / meets the icon cleanly.
Width = lane-right (1.2em from lane center) + row padding-left
(0.8em) = 2em. */
.tree-prefix .lane-joint::after {
content: '';
position: absolute;
left: 0.6em;
top: 3.1em;
width: 2em;
border-top: 1px solid currentColor;
} }
/* Live cards get the icon-left / body-right split; tombstone rows keep /* Live cards get the icon-left / body-right split; tombstone rows keep
the plain stacked block layout. The icon is a background-image div the plain stacked block layout. The icon is a background-image div