diff --git a/docs/superpowers/plans/2026-04-24-phase-3-graph-view.md b/docs/superpowers/plans/2026-04-24-phase-3-graph-view.md new file mode 100644 index 00000000..82f882bb --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-phase-3-graph-view.md @@ -0,0 +1,3207 @@ +# Phase 3 Relationship Graph View — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the Phase 3 relationship graph view per the design spec — a dedicated `/graph?entity=&depth=` route plus a `List | Graph` mode toggle on the three list pages, with click-to-pivot and click-to-expand interactions, hand-rolled SVG rendering, and an always-visible AAA parallel edge table. + +**Architecture:** Go server walks the existing `ldap_cache` via BFS to build a `{nodes, edges, overflow}` JSON blob with server-assigned `(ring, angle)` for each node. Handler renders a Templ page embedding that JSON in a ` + } +} + +func graphTitle(vm GraphPageVM) string { + if vm.Data.Focus == "" { + return "Graph view" + } + return "Relationships: " + vm.FocusLabel +} +``` + +The `graphDepthSlider`, `graphSVG`, `graphEdgeTable`, `graphInlineJSON` sub-components are defined in Tasks 16–19. + +- [ ] **Step 2: Run `templ generate`** + +Run: `templ generate` +Expected: `graph_v2_templ.go` created; 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add internal/web/templates/graph_v2.templ internal/web/templates/graph_v2_templ.go +git commit -S --signoff -m "feat(templ): graph page skeleton + view-model" +``` + +(Recall `*_templ.go` is .gitignored — the generated file is regenerated by CI.) + +### Task 16 — Depth slider + overflow component + +**Files:** +- Modify: `internal/web/templates/graph_v2.templ` + +- [ ] **Step 1: Append the sub-components** + +```go +templ graphDepthSlider(current int, focusDN string) { +
+ + + + { fmt.Sprintf("%d", current) } + +
+} +``` + +Slice 4's JS will listen to the slider's `input` event and navigate on change (avoiding the Apply button). No-JS users see the button. + +- [ ] **Step 2: Regenerate + commit** + +Run: `templ generate` + +```bash +git add internal/web/templates/graph_v2.templ +git commit -S --signoff -m "feat(templ): graph depth slider with no-JS fallback button" +``` + +### Task 17 — SSR SVG nodes + edges + +**Files:** +- Modify: `internal/web/templates/graph_v2.templ` + +- [ ] **Step 1: Append `graphSVG`, `graphNode`, `graphEdgeLine`** + +```go +templ graphSVG(data *ldap_cache.GraphData, focusLabel string) { + + { "Relationship graph for " + focusLabel } + + { fmt.Sprintf("Shows %d relationships across %d entities.", len(data.Edges), len(data.Nodes)) } + + + for _, e := range data.Edges { + @graphEdgeLine(e, data.Nodes) + } + for _, n := range data.Nodes { + @graphNode(n) + } + + +} + +templ graphEdgeLine(e ldap_cache.Edge, nodes []ldap_cache.Node) { + {{ sx, sy := nodeXY(e.Source, nodes) }} + {{ tx, ty := nodeXY(e.Target, nodes) }} + +} + +templ graphNode(n ldap_cache.Node) { + {{ x, y := concentricXY(n.Ring, n.Angle) }} + + + { n.Label } + if n.Expandable { + + } + +} + +// concentricXY converts (ring, angle) to canvas coords. Ring 0 → origin; +// Ring r → radius r × 180 (viewBox is 1000×1000 centred at origin). +func concentricXY(ring int, angle float64) (float64, float64) { + if ring == 0 { + return 0, 0 + } + r := float64(ring) * 180 + return r * math.Cos(angle), r * math.Sin(angle) +} + +// nodeXY looks up a node's coordinates by DN for edge endpoint rendering. +func nodeXY(dn string, nodes []ldap_cache.Node) (float64, float64) { + for _, n := range nodes { + if n.DN == dn { + return concentricXY(n.Ring, n.Angle) + } + } + return 0, 0 +} + +func graphNodeLabel(n ldap_cache.Node) string { + switch n.Type { + case ldap_cache.NodeGroup: + count := 0 + if n.MemberCount != nil { + count = *n.MemberCount + } + return fmt.Sprintf("Group %s (%d members). Press Enter to open.", n.Label, count) + case ldap_cache.NodeUser: + state := "enabled" + if n.Enabled != nil && !*n.Enabled { + state = "disabled" + } + return fmt.Sprintf("User %s (%s). Press Enter to open.", n.Label, state) + case ldap_cache.NodeComputer: + return fmt.Sprintf("Computer %s. Press Enter to open.", n.Label) + case ldap_cache.NodeOU: + return fmt.Sprintf("Organisational unit %s. Press Enter to open.", n.Label) + } + return n.Label +} +``` + +Add `"math"` to imports. + +- [ ] **Step 2: Regenerate + commit** + +Run: `templ generate` + +```bash +git add internal/web/templates/graph_v2.templ +git commit -S --signoff -m "feat(templ): SSR SVG rendering for graph nodes + edges" +``` + +### Task 18 — SSR edge table + +**Files:** +- Modify: `internal/web/templates/graph_v2.templ` + +- [ ] **Step 1: Append `graphEdgeTable` with sort-header anchors** + +```go +templ graphEdgeTable(vm GraphPageVM) { + + + + + + + + + + + + for _, e := range sortEdges(vm.Data, vm) { + @graphEdgeRow(e, vm.Data.Nodes) + } + +
@graphSortHeader("ring", "Ring", vm)@graphSortHeader("from", "From", vm)@graphSortHeader("edge", "Edge", vm)@graphSortHeader("to", "To", vm)@graphSortHeader("type", "Type", vm)
+} + +templ graphSortHeader(key, label string, vm GraphPageVM) { + + { label } + +} + +templ graphEdgeRow(e ldap_cache.Edge, nodes []ldap_cache.Node) { + {{ source, sourceType := nodeDesc(e.Source, nodes) }} + {{ target, targetType := nodeDesc(e.Target, nodes) }} + {{ ring := ringForEdge(e, nodes) }} + + { fmt.Sprintf("%d", ring) } + { source } + { edgeLabel(e.Kind) } + { target } + { string(targetType) } + +} + +func edgeLabel(k ldap_cache.EdgeKind) string { + switch k { + case ldap_cache.EdgeMemberOf: + return "member of" + case ldap_cache.EdgeContains: + return "contains" + } + return string(k) +} + +// sortEdges, ringForEdge, nodeDesc, entityHref, buildSortHref, +// graphSortClass are straightforward helpers — implement alongside. +``` + +- [ ] **Step 2: Implement the helper functions** + +Append to `graph_v2.templ`'s Go section: + +```go +func nodeDesc(dn string, nodes []ldap_cache.Node) (label string, t ldap_cache.NodeType) { + for _, n := range nodes { + if n.DN == dn { + return n.Label, n.Type + } + } + return dn, "" +} + +func ringForEdge(e ldap_cache.Edge, nodes []ldap_cache.Node) int { + // The edge's ring is max(source.Ring, target.Ring). Centre node is + // ring 0; the edge connects 0 to ring 1 → ring=1. Etc. + sr, tr := 0, 0 + for _, n := range nodes { + if n.DN == e.Source { + sr = n.Ring + } + if n.DN == e.Target { + tr = n.Ring + } + } + if sr > tr { + return sr + } + return tr +} + +func entityHref(dn string, t ldap_cache.NodeType) string { + switch t { + case ldap_cache.NodeUser: + return "/users/" + url.PathEscape(dn) + case ldap_cache.NodeGroup: + return "/groups/" + url.PathEscape(dn) + case ldap_cache.NodeComputer: + return "/computers/" + url.PathEscape(dn) + case ldap_cache.NodeOU: + return "/users?ou=" + url.QueryEscape(dn) + } + return "#" +} + +func sortEdges(data *ldap_cache.GraphData, vm GraphPageVM) []ldap_cache.Edge { + out := make([]ldap_cache.Edge, len(data.Edges)) + copy(out, data.Edges) + sort.SliceStable(out, func(i, j int) bool { + return edgeLess(out[i], out[j], data.Nodes, vm) + }) + return out +} + +func edgeLess(a, b ldap_cache.Edge, nodes []ldap_cache.Node, vm GraphPageVM) bool { + // Default: ring asc, source label asc, target label asc. + ra, rb := ringForEdge(a, nodes), ringForEdge(b, nodes) + if ra != rb { + return ra < rb + } + sla, _ := nodeDesc(a.Source, nodes) + slb, _ := nodeDesc(b.Source, nodes) + if sla != slb { + return sla < slb + } + tla, _ := nodeDesc(a.Target, nodes) + tlb, _ := nodeDesc(b.Target, nodes) + return tla < tlb +} + +func buildSortHref(vm GraphPageVM, key string) string { + // For Task 18 we ship ascending-only; Task 19 adds toggle. + return fmt.Sprintf("/graph?entity=%s&depth=%d&sort=%s", + url.QueryEscape(vm.Data.Focus), vm.Data.Depth, key) +} + +func graphSortClass(key string, vm GraphPageVM) string { + // Populate when Task 19 tracks current sort state. + return "" +} +``` + +Add `"net/url"`, `"sort"` to imports. + +- [ ] **Step 3: Regenerate + commit** + +Run: `templ generate` + +```bash +git add internal/web/templates/graph_v2.templ +git commit -S --signoff -m "feat(templ): SSR edge table with entity links" +``` + +### Task 19 — Embedded JSON script block + graphInlineJSON helper + +**Files:** +- Modify: `internal/web/templates/graph_v2.templ` + +- [ ] **Step 1: Add the helper** + +```go +// graphInlineJSON returns the marshalled GraphData. The template embeds +// it inside +``` + +Run `templ generate`. + +- [ ] **Step 3: Commit** + +```bash +git add internal/web/static/js/v2-graph.js internal/web/templates/base_v2.templ +git commit -S --signoff -m "feat(js): v2-graph.js skeleton (parses embedded JSON, locates canvas)" +``` + +### Task 24 — Pan + zoom + +**Files:** +- Modify: `internal/web/static/js/v2-graph.js` + +- [ ] **Step 1: Implement `wirePanZoom`** + +Replace the stub: + +```js +function wirePanZoom(svg, viewport) { + var tx = 0, ty = 0, scale = 1; + var dragging = false, sx = 0, sy = 0; + + function apply() { + viewport.setAttribute('transform', 'translate(' + tx + ',' + ty + ') scale(' + scale + ')'); + } + + svg.addEventListener('mousedown', function (e) { + if (e.target !== svg && !e.target.classList.contains('graph-viewport')) return; + dragging = true; + sx = e.clientX - tx; + sy = e.clientY - ty; + e.preventDefault(); + }); + window.addEventListener('mousemove', function (e) { + if (!dragging) return; + tx = e.clientX - sx; + ty = e.clientY - sy; + apply(); + }); + window.addEventListener('mouseup', function () { dragging = false; }); + + svg.addEventListener('wheel', function (e) { + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + var delta = -e.deltaY * 0.001; + scale = Math.min(3, Math.max(0.3, scale * (1 + delta))); + apply(); + }, { passive: false }); + + // Arrow-key pan when canvas is focused. + svg.addEventListener('keydown', function (e) { + var step = 32; + switch (e.key) { + case 'ArrowLeft': tx += step; apply(); e.preventDefault(); break; + case 'ArrowRight': tx -= step; apply(); e.preventDefault(); break; + case 'ArrowUp': ty += step; apply(); e.preventDefault(); break; + case 'ArrowDown': ty -= step; apply(); e.preventDefault(); break; + case '+': case '=': scale = Math.min(3, scale * 1.1); apply(); e.preventDefault(); break; + case '-': case '_': scale = Math.max(0.3, scale / 1.1); apply(); e.preventDefault(); break; + } + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add internal/web/static/js/v2-graph.js +git commit -S --signoff -m "feat(js): pan + zoom (mouse, wheel+ctrl, arrow keys)" +``` + +### Task 25 — Keyboard navigation between nodes + +**Files:** +- Modify: `internal/web/static/js/v2-graph.js` + +- [ ] **Step 1: Implement `wireKeyboardNav`** + +```js +function wireKeyboardNav(svg) { + var nodes = Array.prototype.slice.call(svg.querySelectorAll('.graph-node')); + // Sort by ring, then angle (they're already in that order in DOM because + // the template writes them per-ring; but be defensive). + var index = 0; + function focusAt(i) { + index = ((i % nodes.length) + nodes.length) % nodes.length; + nodes[index].focus(); + } + svg.addEventListener('keydown', function (e) { + if (e.target.classList.contains('graph-node')) { + if (e.key === 'Tab' && !e.shiftKey) { focusAt(index + 1); e.preventDefault(); } + else if (e.key === 'Tab' && e.shiftKey) { focusAt(index - 1); e.preventDefault(); } + } + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add internal/web/static/js/v2-graph.js +git commit -S --signoff -m "feat(js): keyboard nav cycles graph nodes in ring order" +``` + +### Task 26 — Click-to-pivot and click-to-expand + +**Files:** +- Modify: `internal/web/static/js/v2-graph.js` + +- [ ] **Step 1: Implement `wireNodeClicks`** + +```js +function wireNodeClicks(svg, state) { + svg.addEventListener('click', function (e) { + var node = e.target.closest('.graph-node'); + if (!node) return; + var dn = node.getAttribute('data-dn'); + var type = node.getAttribute('data-type'); + var expandable = node.getAttribute('data-expandable') === 'true'; + var clickedBadge = !!e.target.closest('.graph-node__expand-badge'); + + if (expandable && clickedBadge) { + expandNode(dn, state, svg); + } else { + pivotToDrawer(dn, type); + } + }); + svg.addEventListener('keydown', function (e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var node = e.target.closest('.graph-node'); + if (!node) return; + var dn = node.getAttribute('data-dn'); + var type = node.getAttribute('data-type'); + var expandable = node.getAttribute('data-expandable') === 'true'; + e.preventDefault(); + if (expandable) expandNode(dn, state, svg); + else pivotToDrawer(dn, type); + }); +} + +function pivotToDrawer(dn, type) { + var base = { user: '/users/', group: '/groups/', computer: '/computers/', ou: '/users?ou=' }[type]; + if (!base) return; + var href = type === 'ou' ? base + encodeURIComponent(dn) : base + encodeURIComponent(dn); + window.location.href = href; +} + +function announce(msg) { + var el = document.getElementById('graph-announce'); + if (el) { el.textContent = ''; setTimeout(function () { el.textContent = msg; }, 10); } +} + +function expandNode(dn, state, svg) { + var url = '/api/graph.json?entity=' + encodeURIComponent(dn) + '&depth=1'; + fetch(url, { credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + var added = 0; + var existingDNs = {}; + state.nodes.forEach(function (n) { existingDNs[n.dn] = true; }); + data.nodes.forEach(function (n) { + if (existingDNs[n.dn]) return; + n.ring = (state.nodes.find(function (x) { return x.dn === dn; }) || {}).ring + 1 || 2; + state.nodes.push(n); + renderNode(svg, n); + added++; + }); + data.edges.forEach(function (e) { + if (!state.edges.some(function (x) { return x.source === e.source && x.target === e.target && x.kind === e.kind; })) { + state.edges.push(e); + renderEdge(svg, e, state.nodes); + } + }); + // Mark clicked node as non-expandable + var el = svg.querySelector('.graph-node[data-dn="' + CSS.escape(dn) + '"]'); + if (el) { + el.setAttribute('data-expandable', 'false'); + var badge = el.querySelector('.graph-node__expand-badge'); + if (badge) badge.remove(); + } + announce('Expanded ' + dn + ': added ' + added + ' nodes.'); + }); +} + +function renderNode(svg, n) { + var ns = 'http://www.w3.org/2000/svg'; + var viewport = svg.querySelector('.graph-viewport'); + var r = n.ring * 180; + var x = r * Math.cos(n.angle), y = r * Math.sin(n.angle); + var g = document.createElementNS(ns, 'g'); + g.setAttribute('class', 'graph-node graph-node--' + n.type + ' graph-node--added'); + g.setAttribute('transform', 'translate(' + x + ',' + y + ')'); + g.setAttribute('tabindex', '0'); + g.setAttribute('role', 'button'); + g.setAttribute('data-dn', n.dn); + g.setAttribute('data-type', n.type); + g.setAttribute('data-expandable', String(!!n.expandable)); + var circ = document.createElementNS(ns, 'circle'); + circ.setAttribute('r', '28'); + circ.setAttribute('class', 'graph-node__disc'); + g.appendChild(circ); + var text = document.createElementNS(ns, 'text'); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('y', '4'); + text.setAttribute('class', 'graph-node__label'); + text.textContent = n.label; + g.appendChild(text); + viewport.appendChild(g); +} + +function renderEdge(svg, e, nodes) { + var ns = 'http://www.w3.org/2000/svg'; + var viewport = svg.querySelector('.graph-viewport'); + function xy(dn) { + var n = nodes.find(function (x) { return x.dn === dn; }); + if (!n) return [0, 0]; + var r = n.ring * 180; + return [r * Math.cos(n.angle), r * Math.sin(n.angle)]; + } + var s = xy(e.source), t = xy(e.target); + var line = document.createElementNS(ns, 'line'); + line.setAttribute('class', 'graph-edge graph-edge--' + e.kind); + line.setAttribute('x1', s[0]); line.setAttribute('y1', s[1]); + line.setAttribute('x2', t[0]); line.setAttribute('y2', t[1]); + line.setAttribute('data-source', e.source); + line.setAttribute('data-target', e.target); + viewport.insertBefore(line, viewport.firstChild); // render behind nodes +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add internal/web/static/js/v2-graph.js +git commit -S --signoff -m "feat(js): click-to-pivot + click-to-expand with aria-live announce" +``` + +### Task 27 — Depth slider JS + responsive resize + +**Files:** +- Modify: `internal/web/static/js/v2-graph.js` + +- [ ] **Step 1: Implement `wireDepthSlider`** + +```js +function wireDepthSlider() { + var slider = document.querySelector('[data-graph-slider]'); + if (!slider) return; + var out = document.querySelector('.graph-slider__value'); + slider.addEventListener('input', function () { if (out) out.textContent = slider.value; }); + slider.addEventListener('change', function () { + var form = slider.form; + if (form) form.submit(); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add internal/web/static/js/v2-graph.js +git commit -S --signoff -m "feat(js): depth slider auto-submits on change (no button)" +``` + +### Task 28 — Slice 4 E2E test + +**Files:** +- Create: `internal/e2e/graph_test.go` (or similar — follow existing e2e test naming) + +- [ ] **Step 1: Write the happy-path Playwright test** + +Use the existing e2e patterns in `internal/e2e/` (hook into Playwright via `chromedp` or whatever the project uses — follow what Phase 1 slices did). The test: + +```go +//go:build e2e + +package e2e + +import "testing" + +func TestGraphHappyPath(t *testing.T) { + // 1. Sign in as admin, navigate to /users. + // 2. Click first user's row. + // 3. In drawer, click "View relationships". + // 4. Assert URL is /graph?entity=...&depth=2. + // 5. Assert is present. + // 6. Assert is present. + // 7. Click an expandable group node. + // 8. Assert aria-live announce fires with "Expanded". + // 9. Assert new appears in the table. + // 10. Assert reduced-motion snapshot: repeat with + // page.emulate_media({'reducedMotion':'reduce'}) — no transition. +} +``` + +- [ ] **Step 2: Run + iterate** + +Run: `go test -tags e2e ./internal/e2e/ -run TestGraphHappyPath -v` + +- [ ] **Step 3: Commit** + +```bash +git add internal/e2e/graph_test.go +git commit -S --signoff -m "test(e2e): graph view — happy path, expand, reduced-motion" +``` + +### Task 29 — Slice 4 wrap + +Run: `go test -tags e2e ./internal/e2e/ ./internal/web/ -count=1` +Expected: all green. + +--- + +## Slice 5 — List-Page Graph Mode + +### Task 30 — Extend `BuildGraph` for list-mode input + +**Files:** +- Modify: `internal/ldap_cache/graph.go` +- Modify: `internal/ldap_cache/graph_test.go` + +- [ ] **Step 1: Add `BuildListGraph`** + +```go +// BuildListGraph builds a Graph for list-page mode: the filtered set +// plus each member's direct groups. Focus is "" and no node has ring 0. +// Users/computers live in ring 2; groups in ring 1. +func (m *Manager) BuildListGraph(filtered []ldap.User, filteredComputers []ldap.Computer) *GraphData { + data := &GraphData{Focus: "", Depth: 1} + seen := map[string]int{} + + for _, u := range filtered { + if _, dup := seen[u.DN()]; !dup { + data.Nodes = append(data.Nodes, userNode(u, 2, false)) + seen[u.DN()] = 2 + } + for _, gDN := range u.Groups { + if g, ok := m.Groups.FindByDN(gDN); ok { + if _, dup := seen[gDN]; !dup { + data.Nodes = append(data.Nodes, groupNode(*g, 1, false)) + seen[gDN] = 1 + } + data.Edges = append(data.Edges, Edge{Source: u.DN(), Target: gDN, Kind: EdgeMemberOf}) + } + } + } + for _, c := range filteredComputers { + if _, dup := seen[c.DN()]; !dup { + data.Nodes = append(data.Nodes, computerNode(c, 2)) + seen[c.DN()] = 2 + } + for _, gDN := range c.Groups { + if g, ok := m.Groups.FindByDN(gDN); ok { + if _, dup := seen[gDN]; !dup { + data.Nodes = append(data.Nodes, groupNode(*g, 1, false)) + seen[gDN] = 1 + } + data.Edges = append(data.Edges, Edge{Source: c.DN(), Target: gDN, Kind: EdgeMemberOf}) + } + } + } + + applyCaps(data) + assignConcentric(data) + return data +} +``` + +- [ ] **Step 2: Write a test** + +```go +func TestBuildListGraph_FilteredUsers(t *testing.T) { + m := graphFixture(t) + filtered := m.Users.Filter(func(u ldap.User) bool { + return strings.Contains(u.DN(), "ou=Engineering") + }) + data := m.BuildListGraph(filtered, nil) + + // Expect: bob, dave, alice (users) + admins, engineers (groups) = 5 nodes + if got := len(data.Nodes); got != 5 { + t.Errorf("node count: got %d, want 5", got) + } + if data.Focus != "" { + t.Errorf("Focus should be empty for list mode, got %q", data.Focus) + } +} +``` + +- [ ] **Step 3: Run + commit** + +```bash +go test ./internal/ldap_cache/ -run TestBuildListGraph -count=1 -v +git add internal/ldap_cache/graph.go internal/ldap_cache/graph_test.go +git commit -S --signoff -m "feat(cache): BuildListGraph for list-page Graph mode" +``` + +### Task 31 — `view=graph` branch in users handler + +**Files:** +- Modify: `internal/web/users_v2_handler.go` + +- [ ] **Step 1: Add the branch** + +Find `handleUsersV2` (list handler). Before the normal list render, insert: + +```go +if c.Query("view") == "graph" { + users := a.filterUsers(c) // whatever helper applies the current filters + data := a.ldapCache.BuildListGraph(users, nil) + vm := templates.GraphPageVM{Data: data, FocusLabel: "", FocusType: ""} + return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm)) +} +``` + +Use the existing filter helper from the list handler — if it is inline, extract it into `filterUsers`. + +- [ ] **Step 2: Add an integration test** + +```go +func TestUsersListGraphMode(t *testing.T) { + env := skipIfNoLDAP(t) + app, _ := setupIntegrationTestApp(t, env) + seedLDAPData(t, env) + app.ldapCache.Refresh() + + cookies := createAuthSession(t, app.sessionStore) + req := httptest.NewRequest("GET", "/users?view=graph", nil) + for _, ck := range cookies { req.AddCookie(ck) } + resp, _ := app.fiber.Test(req, 5000) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + t.Fatalf("status: %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `id="graph-canvas"`) { + t.Error("missing graph canvas in list mode response") + } +} +``` + +- [ ] **Step 3: Run + commit** + +```bash +go test ./internal/web/ -run TestUsersListGraphMode -count=1 -v +git add internal/web/users_v2_handler.go internal/web/graph_integration_test.go +git commit -S --signoff -m "feat(web): /users?view=graph list-page mode" +``` + +### Task 32 — Mirror for groups + computers handlers + +**Files:** +- Modify: `internal/web/groups_v2_handler.go` +- Modify: `internal/web/computers_v2_handler.go` + +- [ ] **Step 1: Groups `view=graph`** + +Filtered groups as edges between group and its members' groups is cumbersome. For list-mode on `/groups`, treat the filtered groups as ring 1 and their direct members as ring 2: + +```go +if c.Query("view") == "graph" { + groups := a.filterGroups(c) + members := make([]ldap.User, 0) + computers := make([]ldap.Computer, 0) + for _, g := range groups { + for _, mDN := range g.Members { + if u, ok := a.ldapCache.Users.FindByDN(mDN); ok { members = append(members, *u) } + if c2, ok := a.ldapCache.Computers.FindByDN(mDN); ok { computers = append(computers, *c2) } + } + } + data := a.ldapCache.BuildListGraph(members, computers) + vm := templates.GraphPageVM{Data: data} + return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm)) +} +``` + +- [ ] **Step 2: Computers `view=graph`** + +```go +if c.Query("view") == "graph" { + computers := a.filterComputers(c) + data := a.ldapCache.BuildListGraph(nil, computers) + vm := templates.GraphPageVM{Data: data} + return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm)) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/web/groups_v2_handler.go internal/web/computers_v2_handler.go +git commit -S --signoff -m "feat(web): /groups + /computers view=graph list-page modes" +``` + +### Task 33 — Segmented List | Graph control in list templates + +**Files:** +- Modify: `internal/web/templates/users_v2.templ` +- Modify: `internal/web/templates/groups_v2.templ` +- Modify: `internal/web/templates/computers_v2.templ` + +- [ ] **Step 1: Add the control to each list template** + +Insert at the top of the list-page main content: + +```go +templ listGraphToggle(base string, currentView string, filters string) { +
+ List + Graph +
+} + +func toggleClass(current, target string) string { + if current == target { return "graph-segmented__option--active" } + return "" +} +func toggleAriaPressed(current, target string) string { + if current == target { return "true" } + return "false" +} +func prefixWithAmp(q string) string { + if q == "" || strings.HasPrefix(q, "?") { return q } + return "&" + strings.TrimPrefix(q, "?") +} +``` + +Inject `@listGraphToggle("/users", c.Query("view"), queryStringWithoutView(c))` (or the groups/computers base) in each list template. + +- [ ] **Step 2: Add CSS for the segmented control** + +Append to `app.css`: + +```css +.graph-segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; } +.graph-segmented__option { padding: 0.4rem 1rem; color: var(--fg-muted); text-decoration: none; } +.graph-segmented__option:hover { background: var(--bg-subtle); } +.graph-segmented__option--active { background: var(--accent); color: var(--bg); } +.graph-segmented__option:focus-visible { outline: 2px solid var(--border-strong); outline-offset: 2px; } +``` + +- [ ] **Step 3: Regenerate + commit** + +```bash +templ generate +git add internal/web/templates/users_v2.templ internal/web/templates/groups_v2.templ internal/web/templates/computers_v2.templ internal/web/static/app.css +git commit -S --signoff -m "feat(ui): List | Graph segmented control on list pages" +``` + +### Task 34 — Slice 5 wrap + +Run: `go test ./internal/web/ ./internal/web/templates/ ./internal/ldap_cache/ -count=1` + +--- + +## Slice 6 — Drawer Pivots + AAA Ratcheting + Docs + +### Task 35 — "View relationships" pivot in user drawer + +**Files:** +- Modify: `internal/web/templates/user_drawer_fragment.templ` (or `users_v2.templ` where the pivot section is defined) + +- [ ] **Step 1: Locate the pivot section** + +Run: `grep -n 'Pivot\|drawer__pivot' internal/web/templates/users_v2.templ` +Find the `