From 259abcef6e704c7f0e0ca1ba1fbb0fc7261ddffb Mon Sep 17 00:00:00 2001 From: Aaron Newton Date: Sun, 21 Jun 2026 10:48:31 -0700 Subject: [PATCH] Fix BUG-011: wrap fanned plan-DAG group members onto multiple lines --- context/knowledge/gotchas/dag-rendering.md | 1 + internal/tui/planview/planview.go | 132 ++++++++++----- internal/tui/planview/render_test.go | 156 +++++++++++++++++- .../changes/fix-bug-011-fan-wrap/proposal.md | 53 ++++++ .../specs/hera-view/spec.md | 32 ++++ .../changes/fix-bug-011-fan-wrap/tasks.md | 23 +++ 6 files changed, 351 insertions(+), 46 deletions(-) create mode 100644 openspec/changes/fix-bug-011-fan-wrap/proposal.md create mode 100644 openspec/changes/fix-bug-011-fan-wrap/specs/hera-view/spec.md create mode 100644 openspec/changes/fix-bug-011-fan-wrap/tasks.md diff --git a/context/knowledge/gotchas/dag-rendering.md b/context/knowledge/gotchas/dag-rendering.md index 4878ddb8..b412bdf6 100644 --- a/context/knowledge/gotchas/dag-rendering.md +++ b/context/knowledge/gotchas/dag-rendering.md @@ -27,4 +27,5 @@ The `dagview` widget (`internal/tui/dagview/`) renders a layered top-down graph. - **planview has a HORIZONTAL viewport that ensure-visibles the cursor (BUG-010), the twin of the existing vertical `scrollOffsetFor`.** When the WIDEST stage block overflows the diagram width, `drawStages` left-aligns every block at `inner.X` and shifts it left by `w.xOffset`, which `ensureCursorVisibleX` recomputes each Draw so the cursor's selected box (border included) is fully within the region — minimal-scroll from the prior offset, with a 1-col `edgeGutter` reserved at each edge. When everything fits, `xOffset==0` and each stage stays CENTERED (unchanged; `SetData` resets `xOffset`). The box positions are REUSED from the dagview-derived layout — the selected box's `(selRelX, selW)` is threaded out of `buildStageBlock` (and `layoutFannedGroup` returns the selected MEMBER's geometry); never relayout-to-fit / wrap / shrink. Off-screen siblings are signalled by dim `‹`/`›` indicators (`edgeMoreLeft`/`edgeMoreRight`) drawn at the pane edge on each overflowing stage's middle row; the gutter keeps them off the selected box. Painting stays clipped to the `clipRect` (no `screen.Sync`, full-rect coverage). `xOffset` is NOT folded into `branchShape` — the cursor already is, and cursor change is what drives the scroll. - **`OnBranchChange` must be wired to `forceRedraw` (log-only, never `Sync`).** The snapshot install, focus flip, cursor move, group fan-out, and drill-in each shift the cell set in the same rect — tcell's per-cell diff leaves ghosts otherwise. Stale cells are prevented by `tview.Clear()` + the widget's full-rect `DrawBorderedPanel`/`FillArea`. See `gotchas/ui-threading.md`. `planview.branchShape` folds the (stage, slot, member) cursor, fanned-group state, and the current-orchestrator title hash into one uint64 so back-to-back `SetData` with the same shape doesn't spam `forceRedraw`. +- **A fanned group's member boxes WRAP onto multiple rows to fit the pane width (BUG-011 fan-wrap) — never a single overflowing row.** `layoutFannedGroup` takes the diagram inner width (threaded through `buildStageBlock(s, availW)`) and packs member boxes left-to-right into rows bounded by `availW - labelCol - 2*groupPad - 2` (clamped ≥1); a member box wider than that budget still occupies its own row. The enclosure height grows to hold the wrapped rows (`boxH = sum(rowHeights) + 2`) and `←→` walks the member index linearly across rows (stepping off a row's end lands on the next row's first member — the cursor model is unchanged, only the visual position wraps). The inter-stage `│` connector re-anchors automatically because `drawStages` uses the block's (now taller) `height`/`width` for `y += b.height` and `ec = bx + b.width/2`. Wrapping removes horizontal overflow in the common case, so BUG-010's `‹`/`›` viewport only fires for a lone over-wide member box; `selMemRelX/selMemW` (the selected member's X within the enclosure) is still returned for that path. Distinct from the older in-code `(BUG-011)` annotations (collapsed-group count icons) — a different bug number. - **Enter (`ActivateCursor`) and Space (`ToggleCursorFan`) are SPLIT in planview (BUG-013 + follow-up).** Enter on a fanned-group MEMBER (`cursor.Member >= 0`) navigates — fires the member's leaf action via `CurrentNodeID()` (`OnEnter`, or `OnDrillIn` when Drillable), exactly like a lone leaf — and does NOT collapse. `ActivateCursor` delegates the non-member group cases (collapsed → fan out; fanned enclosure `Member < 0` → collapse) to `ToggleCursorFan`. **Space is a PURE toggle: never navigates** — it fans out a collapsed group and collapses a fanned one regardless of member, and is a no-op on a lone-node slot (opening a leaf is Enter-only). Collapsing a fanned group is Space's or `Esc`'s (`EscBack`) job, never Enter-on-member. diff --git a/internal/tui/planview/planview.go b/internal/tui/planview/planview.go index a10054a2..0b9a11be 100644 --- a/internal/tui/planview/planview.go +++ b/internal/tui/planview/planview.go @@ -1550,7 +1550,7 @@ func (w *Widget) drawStages(screen tcell.Screen, inner widget.InnerRect) { blocks := make([]stageBlock, len(w.stages)) for s := range w.stages { - blocks[s] = w.buildStageBlock(s) + blocks[s] = w.buildStageBlock(s, inner.W) } // Total block height: each stage's height plus a 1-row connector between. @@ -1712,7 +1712,9 @@ func (w *Widget) scrollOffsetFor(blocks []stageBlock, regionH int) int { // (lone nodes as rounded boxes, collapsed groups as dashed boxes, fanned groups // as a dashed enclosure wrapping the member node-boxes). The block's width is the // sum of sibling widths + boxGap between them; its height is the tallest sibling. -func (w *Widget) buildStageBlock(s int) stageBlock { +// availW is the diagram's inner width, used so a fanned group wraps its member +// boxes onto multiple rows to fit the pane instead of overflowing (BUG-011). +func (w *Widget) buildStageBlock(s int, availW int) stageBlock { type sib struct { width, height int draw func(screen tcell.Screen, x, y int, clip clipRect) @@ -1727,7 +1729,7 @@ func (w *Widget) buildStageBlock(s int) stageBlock { onSlot := isCursorStage && w.cursor.Slot == slotIdx switch { case sl.group != nil && w.Fanned(s, slotIdx): - bw, bh, draw, memRelX, memW := w.layoutFannedGroup(s, slotIdx, sl.group) + bw, bh, draw, memRelX, memW := w.layoutFannedGroup(s, slotIdx, sl.group, availW) sb := sib{width: bw, height: bh, draw: draw} if onSlot { if memW > 0 { // a member is selected inside the fanned group @@ -1860,22 +1862,27 @@ func (w *Widget) layoutDashedBox(label string, sub []countSeg, topLabel string, } // layoutFannedGroup lays a fanned group out as a SOLID rounded enclosure wrapping -// the member node-boxes laid out horizontally inside (BUG-005, matching the -// design). The enclosure carries the group's role label VERTICALLY down its left -// inner edge (one rune per row, dim) and a ▲ collapse affordance at the top-right; -// each member that feeds downstream (g.FeedingMembers) gets a ↘ on its box. The -// cursor's member box gets the selection fill; the enclosure itself fills only -// when the cursor rests on the group slot with no member selected. +// the member node-boxes inside (BUG-005, matching the design). The member boxes +// are packed left-to-right and WRAPPED onto multiple rows so the enclosure fits +// the available diagram width (availW) instead of overflowing in one row +// (BUG-011): a new row starts whenever the next box would exceed the inner-width +// budget, and a box wider than the budget on its own still occupies its own row +// (the BUG-010 horizontal viewport then scrolls to it). The enclosure carries the +// group's role label VERTICALLY down its left inner edge (one rune per row, dim) +// and a ▲ collapse affordance at the top-right; each member that feeds downstream +// (g.FeedingMembers) gets a ↘ on its box. The cursor's member box gets the +// selection (double border); the enclosure itself is selected only when the +// cursor rests on the group slot with no member selected. // The two trailing return values are the selected member's box x-offset // (relative to the enclosure's left edge) and width, for the horizontal viewport // (BUG-010); selMemW is 0 unless the cursor sits on a member of THIS group. -func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tcell.Screen, int, int, clipRect), int, int) { +func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group, availW int) (int, int, func(tcell.Screen, int, int, clipRect), int, int) { onSlot := w.cursor.Stage == s && w.cursor.Slot == slotIdx type mbox struct { width, height int draw func(tcell.Screen, int, int, clipRect) } - var members []mbox + members := make([]mbox, 0, len(g.Members)) for memberIdx, id := range g.Members { glyph, style := w.nodeGlyph(id) content := string(glyph) + " " + w.LabelOf(id) @@ -1885,16 +1892,6 @@ func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tce bw, bh, d := w.layoutNodeBox(content, style, onSlot && w.cursor.Member == memberIdx) members = append(members, mbox{bw, bh, d}) } - innerW, innerH := 0, 0 - for i, m := range members { - innerW += m.width - if i > 0 { - innerW += boxGap - } - if m.height > innerH { - innerH = m.height - } - } // Reserve one extra inner column on the left for the vertical role label when // the group has a common token (else the label column is omitted). vlabel := []rune(w.commonRoleToken(g.Members)) @@ -1902,22 +1899,77 @@ func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tce if len(vlabel) > 0 { labelCol = 1 } + // Inner-width budget for the member rows: the available diagram width minus the + // label column, the enclosure's horizontal padding, and its two borders. At + // least 1 so a degenerate narrow pane still packs one (overflowing) box per row. + budget := availW - labelCol - 2*groupPad - 2 + if budget < 1 { + budget = 1 + } + // Pack member indices into rows: keep adding to the current row until the next + // box would exceed the budget, then start a new row. A row always holds at + // least one box (even if it alone exceeds the budget). + var rows [][]int + var cur []int + curW := 0 + for i, m := range members { + add := m.width + if len(cur) > 0 { + add += boxGap + } + if len(cur) > 0 && curW+add > budget { + rows = append(rows, cur) + cur = nil + curW = 0 + add = m.width + } + cur = append(cur, i) + curW += add + } + if len(cur) > 0 { + rows = append(rows, cur) + } + // Row geometry: each row's width is the sum of its members + gaps; its height + // the tallest member. innerW = widest row, innerH = sum of row heights. + rowHeights := make([]int, len(rows)) + innerW, innerH := 0, 0 + for r, row := range rows { + rw, rh := 0, 0 + for j, mi := range row { + if j > 0 { + rw += boxGap + } + rw += members[mi].width + if members[mi].height > rh { + rh = members[mi].height + } + } + rowHeights[r] = rh + if rw > innerW { + innerW = rw + } + innerH += rh + } boxW := labelCol + innerW + 2*groupPad + 2 boxH := innerH + 2 // rounded top + bottom edges enclosureSel := onSlot && w.cursor.Member < 0 + memBase := 1 + labelCol + groupPad // Selected member geometry (relative to the enclosure box left edge), mirroring - // the member-draw loop's x math below. + // the member-draw loop's x math below. Only the X axis is threaded to the + // horizontal viewport (BUG-010); wrapping keeps the group within the width in + // the common case, so the viewport only fires for a lone over-wide member box. selMemRelX, selMemW := 0, 0 - memBase := 1 + labelCol + groupPad - mcx := memBase - for i, m := range members { - if i > 0 { - mcx += boxGap - } - if onSlot && w.cursor.Member == i { - selMemRelX, selMemW = mcx, m.width + for _, row := range rows { + mcx := memBase + for j, mi := range row { + if j > 0 { + mcx += boxGap + } + if onSlot && w.cursor.Member == mi { + selMemRelX, selMemW = mcx, members[mi].width + } + mcx += members[mi].width } - mcx += m.width } border := w.enclosureBorderStyle(enclosureSel) labelStyle := theme.StyleDimmed @@ -1939,13 +1991,19 @@ func (w *Widget) layoutFannedGroup(s, slotIdx int, g *Group) (int, int, func(tce } setIf(screen, clip, x+1, ry, r, labelStyle) } - cx := x + 1 + labelCol + groupPad - for i, m := range members { - if i > 0 { - cx += boxGap + // Member boxes, row by row. Each row's top is the running sum of prior row + // heights below the enclosure's top border. + ry := y + 1 + for r, row := range rows { + cx := x + memBase + for j, mi := range row { + if j > 0 { + cx += boxGap + } + members[mi].draw(screen, cx, ry, clip) + cx += members[mi].width } - m.draw(screen, cx, y+1, clip) - cx += m.width + ry += rowHeights[r] } } return boxW, boxH, draw, selMemRelX, selMemW diff --git a/internal/tui/planview/render_test.go b/internal/tui/planview/render_test.go index d58f1472..33530b3b 100644 --- a/internal/tui/planview/render_test.go +++ b/internal/tui/planview/render_test.go @@ -593,10 +593,13 @@ func TestDraw_HScroll_EdgeIndicators(t *testing.T) { testutil.Equal(t, strings.ContainsRune(atLast, '›'), false) } -// TestDraw_HScroll_FannedMemberScrollsIntoView (BUG-010): a fanned group wider -// than the pane scrolls so the SELECTED member box is fully visible, with an -// earlier member scrolled off the left. -func TestDraw_HScroll_FannedMemberScrollsIntoView(t *testing.T) { +// TestDraw_FannedGroupWrapsInsteadOfHScroll (BUG-011 supersedes the BUG-010 +// fanned-scroll path): a fanned group wider than the pane no longer scrolls one +// overflowing row horizontally — it WRAPS onto multiple rows, so the selected +// member AND the previously-off-left first member are BOTH fully visible at once, +// and no horizontal-scroll edge indicator is drawn. (Lone-node stages still +// scroll horizontally — see TestDraw_HScroll_SelectedNodeFullyVisible.) +func TestDraw_FannedGroupWrapsInsteadOfHScroll(t *testing.T) { w := fanGroup("1a", "1b", "1c", "1d", "1e", "1f", "1g", "1h") w.SetFocused(true) w.MoveStage(1) // onto the collapsed group @@ -605,12 +608,15 @@ func TestDraw_HScroll_FannedMemberScrollsIntoView(t *testing.T) { w.MoveSlot(1) // walk to the last member (1h) } testutil.Equal(t, w.CurrentNodeID(), "1h") - sc := drawToSim(t, w, 40, 18) - // ○ is the planned-node glyph; inside a fanned group the box label is the - // parsed short-id ("1h"), not the full role name. + sc := drawToSim(t, w, 40, 30) + // The selected last member is fully visible AND the first member is still on + // screen (wrapped to an earlier row), not scrolled off the left. assertBoxFullyVisible(t, sc, "○ 1h") - _, _, found := findStringCell(sc, "○ 1a") - testutil.Equal(t, found, false) + assertBoxFullyVisible(t, sc, "○ 1a") + // No horizontal-scroll indicators — wrapping removed the overflow. + out := drawToString(t, w, 40, 30) + testutil.Equal(t, strings.ContainsRune(out, '‹'), false) + testutil.Equal(t, strings.ContainsRune(out, '›'), false) } // TestDraw_HScroll_NoIndicatorsWhenFits (BUG-010): when every stage fits the pane @@ -687,6 +693,138 @@ func TestGroupCounts_IncludesCancelled(t *testing.T) { testutil.Contains(t, got, "1 ✕") } +// fanGroupFeeding builds a plan stage0 [1a] -> group {members…} (stage1) -> [3a] +// where every member feeds 3a, so the group is a downstream-feeding parallel +// group with stages above AND below it. Used to exercise the wrapped-group edge +// anchoring (BUG-011 fan-wrap). +func fanGroupFeeding(members ...string) *Widget { + w := New() + nodes := []Node{node("1a"), node("3a")} + var edges []Edge + for _, m := range members { + nodes = append(nodes, node(m)) + edges = append(edges, Edge{From: "1a", To: m}, Edge{From: m, To: "3a"}) + } + w.SetData(nodes, edges) + w.SetFocused(true) + return w +} + +// TestDraw_FannedGroupWrapsToFitWidth (BUG-011 fan-wrap): a fanned group with +// more members than fit one row at a narrow pane width wraps its member boxes +// onto MULTIPLE ROWS so every box is fully visible — no overflow off the right +// edge and no horizontal-scroll edge indicators. The first and last members +// land on different rows. +func TestDraw_FannedGroupWrapsToFitWidth(t *testing.T) { + members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"} + // Plain group (no downstream feed) so member boxes carry no `↘` and their + // content matches assertBoxFullyVisible's exact box-width math. + w := fanGroup(members...) + // Fan out the group (stage 1). + w.MoveStage(1) + _, isGroup := w.GroupAt(w.CursorPos().Stage, w.CursorPos().Slot) + testutil.Equal(t, isGroup, true) + w.ActivateCursor() + + const paneW, paneH = 40, 30 + sc := drawToSim(t, w, paneW, paneH) + // Every member box is present AND fully visible (left + right borders on-screen). + for _, m := range members { + assertBoxFullyVisible(t, sc, "○ "+m) + } + // The first and last members are on DIFFERENT rows (the group wrapped). + _, yFirst, okF := findStringCell(sc, "○ 2a") + _, yLast, okL := findStringCell(sc, "○ 2h") + testutil.Equal(t, okF, true) + testutil.Equal(t, okL, true) + testutil.Equal(t, yFirst != yLast, true) + + // Wrapping removed the horizontal overflow → no edge indicators. + out := drawToString(t, w, paneW, paneH) + testutil.Equal(t, strings.ContainsRune(out, '›'), false) + testutil.Equal(t, strings.ContainsRune(out, '‹'), false) +} + +// TestDraw_FannedGroupWrapCursorReachesEveryMember (BUG-011 fan-wrap): with the +// group wrapped onto multiple rows, walking the cursor right from the first +// member advances through every member in order across the rows. +func TestDraw_FannedGroupWrapCursorReachesEveryMember(t *testing.T) { + members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"} + w := fanGroup(members...) + w.MoveStage(1) + w.ActivateCursor() // fan out → cursor on member 0 (2a) + testutil.Equal(t, w.CurrentNodeID(), "2a") + // Render narrow so it wraps; the cursor model is width-independent, but assert + // the rendered grid is multi-row first. + const paneW, paneH = 40, 30 + sc := drawToSim(t, w, paneW, paneH) + _, y0, _ := findStringCell(sc, "○ 2a") + _, y7, _ := findStringCell(sc, "○ 2h") + testutil.Equal(t, y0 != y7, true) + // Walk right through all members; each step names the next member in order. + for i := 1; i < len(members); i++ { + w.MoveSlot(1) + testutil.Equal(t, w.CurrentNodeID(), members[i]) + } +} + +// TestDraw_FannedGroupWrapDownstreamEdgeAnchored (BUG-011 fan-wrap): when a +// wrapped group feeds a downstream stage, the downstream stage's box renders +// BELOW the (now taller) wrapped block, and an inter-stage `│` connector sits +// between them — the edge re-anchors under the taller block. +func TestDraw_FannedGroupWrapDownstreamEdgeAnchored(t *testing.T) { + members := []string{"2a", "2b", "2c", "2d", "2e", "2f", "2g", "2h"} + w := fanGroupFeeding(members...) + w.MoveStage(1) + w.ActivateCursor() // fan out the group + + const paneW, paneH = 40, 30 + sc := drawToSim(t, w, paneW, paneH) + // The downstream node 3a renders below the wrapped group's last member row. + _, yLastMember, okM := findStringCell(sc, "○ 2h") + _, y3a, ok3a := findStringCell(sc, "○ 3a") + testutil.Equal(t, okM, true) + testutil.Equal(t, ok3a, true) + testutil.Equal(t, y3a > yLastMember, true) + // An inter-stage connector `│` sits on a row strictly between the group block + // and the downstream box (the edge anchors under the taller wrapped block). + cells, scw, _ := sc.GetContents() + connectorBetween := false + for y := yLastMember + 1; y < y3a; y++ { + for x := 0; x < scw; x++ { + c := cells[y*scw+x] + if len(c.Runes) > 0 && c.Runes[0] == '│' { + connectorBetween = true + } + } + } + testutil.Equal(t, connectorBetween, true) +} + +// TestDraw_FannedGroupNarrowPaneOnePerRow (BUG-011 fan-wrap, degenerate edge): +// at a pane so narrow that a single member box exceeds the inner-width budget, +// each member wraps to its own row, the enclosure still overflows the pane +// width, and the BUG-010 horizontal viewport keeps the SELECTED member visible +// (drawing a `‹` once scrolled right). Exercises the budget clamp + the fanned +// horizontal-scroll path that wrapping otherwise supersedes. +func TestDraw_FannedGroupNarrowPaneOnePerRow(t *testing.T) { + w := fanGroup("1a", "1b", "1c") + w.MoveStage(1) + w.ActivateCursor() // fan out → cursor on member 0 + w.MoveSlot(1) // walk to the last visible member + w.MoveSlot(1) // (clamps at the last member) + // A very narrow pane: a single ~8-wide member box can't fit the inner budget, + // so each member occupies its own row and the enclosure overflows the width. + sc := drawToSim(t, w, 12, 20) + // The render must not panic and the cursor still names a member box. + id := w.CurrentNodeID() + testutil.Equal(t, id == "1a" || id == "1b" || id == "1c", true) + // Content is hidden to the left after scrolling right → a `‹` indicator. + out := drawToString(t, w, 12, 20) + testutil.Contains(t, out, "‹") + _ = sc +} + func TestTruncateLabel_RuneAware(t *testing.T) { // A long name truncates to fallbackLabelRunes with an ellipsis, rune-counted. long := "verylongrolenamehere" diff --git a/openspec/changes/fix-bug-011-fan-wrap/proposal.md b/openspec/changes/fix-bug-011-fan-wrap/proposal.md new file mode 100644 index 00000000..43d9fd6b --- /dev/null +++ b/openspec/changes/fix-bug-011-fan-wrap/proposal.md @@ -0,0 +1,53 @@ +## Why + +**BUG-011 — a fanned plan-DAG group overflows the pane in one horizontal row.** +When a parallel group is fanned out (Enter on a collapsed group), the plan-view +widget (`internal/tui/planview`) lays every member box in a SINGLE horizontal +row inside the enclosure. With many members the row runs off the right edge — +e.g. `2a 2b 2c 2d land-44-iso… land-46-mem… …` — leaving only a `›` indicator +to hint at the hidden members. The view is unusable for a wide group: the +operator must horizontally scroll one member at a time to see them all. + +The horizontal viewport (BUG-010) keeps the SELECTED member visible by scrolling, +but a single overflowing row is still the wrong shape for a group of more than a +handful of members. + +## What Changes + +- **A fanned group's member boxes wrap onto MULTIPLE ROWS to fit the pane + width.** The enclosure packs member boxes left-to-right, starting a new row + whenever the next box would exceed the available inner width, so the whole + group fits the diagram width instead of overflowing in one row. A member box + wider than the pane on its own still occupies a row of its own (and the BUG-010 + horizontal viewport still scrolls to it). +- **`←→` navigation walks the members in order across the wrapped grid.** The + member cursor index is unchanged; stepping right off the end of one row lands + on the first member of the next row (and left off the start lands on the prior + row's last member), so every member is reachable. +- **The enclosure grows TALLER for the wrapped rows, and the downstream + inter-stage edge re-anchors below it.** The connector that hangs under the + group's center is placed using the block's (now taller) height, so it stays + anchored beneath the wrapped block; each feeding member keeps its `↘`. +- **BUG-010's horizontal viewport + ensure-visible is preserved.** Wrapping + removes the overflow in the common case (no scroll, no `‹`/`›` indicators), but + the X-viewport still handles a lone over-wide member box. + +## Capabilities + +### Modified Capabilities + +- `hera-view`: a fanned group's member boxes now WRAP onto multiple rows to fit + the pane width — previously they were laid out in a single horizontal row that + overflowed the right edge for a group with many members. + +## Impact + +- **Modified code:** + - `internal/tui/planview/planview.go` — `layoutFannedGroup` packs members into + rows bounded by the available width (threaded through `buildStageBlock`); + multi-row enclosure height; member draw + selected-member geometry across + rows. +- **No new key, no new dependency, no schema change, no daemon RPC.** Pure + read-only view/navigation — no edit callbacks added. +- **Specs are LOCAL DOCS only** (`openspec/project.md`): no CI / Make / Go-build + wiring is added or changed. The quality gate stays `make pre-pr`. diff --git a/openspec/changes/fix-bug-011-fan-wrap/specs/hera-view/spec.md b/openspec/changes/fix-bug-011-fan-wrap/specs/hera-view/spec.md new file mode 100644 index 00000000..8ef5fc8f --- /dev/null +++ b/openspec/changes/fix-bug-011-fan-wrap/specs/hera-view/spec.md @@ -0,0 +1,32 @@ +# Hera View + +## MODIFIED Requirements + +### Requirement: Fanned group visually expands into member boxes (area 6) + +When a parallel group is fanned out, the diagram SHALL render its members as individual node boxes inside a SOLID rounded enclosure (matching the design); the enclosure SHALL carry the members' common role token rendered vertically down its left inner edge and a `▲` collapse affordance at its top-right. The member boxes SHALL be packed left-to-right and WRAPPED onto multiple rows so the enclosure fits the available diagram width: a new row is started whenever the next member box would exceed the available inner width, and a member box wider than the available width on its own occupies a row of its own. The enclosure's height SHALL grow to hold the wrapped rows, and the row's centering SHALL account for the wider/taller expanded block. `←→` navigation SHALL walk the members in order across the wrapped grid (stepping off the end of a row continues onto the next row), so every member is reachable. Every member with an out-of-group (downstream) edge SHALL carry a `↘` on its box, and the inter-stage edge below the group SHALL remain anchored beneath the now-taller wrapped block. The BUG-010 horizontal viewport SHALL still ensure-visible the selected member (so a lone over-wide member box scrolls into view). A collapsed group SHALL still render as a dashed range box. + +#### Scenario: Fanning a group expands the diagram into member boxes + +- **WHEN** the cursor is on a collapsed group and the user presses `Enter` +- **THEN** the diagram replaces the range box with one box per member inside a solid rounded enclosure carrying a `▲` collapse affordance, and the range-box label is no longer drawn for that stage + +#### Scenario: A wide fanned group wraps onto multiple rows + +- **WHEN** a fanned group has more member boxes than fit the pane width in one row +- **THEN** the member boxes wrap onto multiple rows so every member box is rendered fully within the diagram region (none overflow the pane and no horizontal-scroll edge indicator is needed), and the first and last members render on different rows + +#### Scenario: The cursor reaches every member across the wrapped rows + +- **WHEN** a fanned group is wrapped onto multiple rows and the cursor is walked with `→` from the first member +- **THEN** the cursor advances through every member in order, including members on later rows, ending on the last member + +#### Scenario: The downstream edge anchors below the wrapped group + +- **WHEN** a fanned group that feeds a downstream stage is wrapped onto multiple rows +- **THEN** the inter-stage connector and the downstream stage's box are rendered below the wrapped group block (not overlapping it) + +#### Scenario: A collapsed group stays a dashed range box + +- **WHEN** a group is not fanned out +- **THEN** the diagram renders it as the collapsed dashed range box diff --git a/openspec/changes/fix-bug-011-fan-wrap/tasks.md b/openspec/changes/fix-bug-011-fan-wrap/tasks.md new file mode 100644 index 00000000..45809e6b --- /dev/null +++ b/openspec/changes/fix-bug-011-fan-wrap/tasks.md @@ -0,0 +1,23 @@ +## 1. Wrap fanned-group members onto multiple rows + +- [x] 1.1 Thread the diagram's available inner width through `buildStageBlock` + into `layoutFannedGroup`. +- [x] 1.2 In `layoutFannedGroup`, pack member boxes left-to-right into rows + bounded by the available inner width (a member always occupies at least its own + row); compute the enclosure's multi-row width/height. +- [x] 1.3 Draw each member at its (row, column) position; keep the vertical role + label, `▲` affordance, and per-member `↘` feed markers. +- [x] 1.4 Compute the selected member's (relX, width) across the wrapped grid so + the BUG-010 horizontal viewport still ensure-visibles a lone over-wide member. + +## 2. Tests + +- [x] 2.1 Render test: a fanned group with many members at a narrow width wraps + onto multiple rows, every member box is fully visible (no overflow / no + `‹`/`›`), and the cursor reaches every member via `←→`. +- [x] 2.2 Render test: the downstream inter-stage connector still draws, anchored + below the now-taller wrapped group block. + +## 3. Docs + +- [x] 3.1 Add a gotcha bullet to `context/knowledge/gotchas/dag-rendering.md`.