Add the SCV graph visual taxonomy to the component library

5229b42bbf7c · AtlantisPleb · · parent 09a775b83fa0

Add the SCV graph visual taxonomy to the component library

Audits Unit (github.com/samuelmtimbo/unit, MIT) as a visual language for
rendering many SCVs at once, and implements the vocabulary natively in
Elixir rather than porting TypeScript.

Why Unit fits: it renders MIMO finite state machines connected by typed
links, and an SCV is exactly that -- a five-state FSM with typed inputs
(work items, themselves a seven-state machine) and typed outputs. The
swarm view is a rendering of the data model scv-planning.md already
specifies, not a metaphor laid over it.

Four rules carry the whole language, extracted from the source:
- shape carries kind (circle = live machine, rect = inert data);
- links meet surfaces, not centers, so drawn length is real separation;
- terminations conform to the surface they touch (arc into a circle,
  flat bar into a rect);
- proximity encodes kind -- link distance is a property of the link type.

The audit argues AGAINST porting Unit's force simulation, which is its
largest visual subsystem. Unit needs it because its graphs are
user-authored and arbitrary; the SCV pipeline is a known staged DAG. A
simulation would move when nothing meaningful changed, which trains an
operator to ignore the one channel that should mean something, and it
would stop position from being an identifier.

Ships as governed components:
- UI.Graph.graph_surface/1 -- the host element, so no caller hand-writes
  vector markup;
- graph_node/1 -- all five SCV states and all seven work-item states;
- graph_link/1 -- all five link kinds, with a step pulse;
- scv_swarm/1 -- fifteen agents in a deterministic staged layout, keyed
  by index so "the third one down" keeps meaning the same agent.

Catalog pages render the full taxonomy rather than describing it, so the
component library is the executable inventory of what each state looks
like. Colour comes only from sanctioned tokens (UI-003) and every status
is carried by ring STYLE as well as hue, so the taxonomy survives a
greyscale screenshot and a colour-blind reader.

Geometry is property-tested at many angles: a link must terminate exactly
on the boundary for every direction, which is the kind of defect that
still looks plausible in one rendering.

Two guards did real work here. The catalog test caught graph_defs/1
rendering duplicate marker ids when used twice -- the same hardcoded-id
flaw the coverage audit documented in account_control/1 -- so markers are
now namespaced by prefix. And the icon-affordance guard caught the inline
vector markup; graph.ex is exempted with a reason (it draws data, not
affordances, and there is no glyph for "a link terminating on this node's
surface at this angle"), while the demos were fixed properly by giving
them a real host component instead of an exemption.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • added docs/2026-08-20-scv-swarm-visualization-unit-audit.md
  • modified lib/openagents_web/component_catalog.ex
  • added lib/openagents_web/components/graph.ex
  • modified lib/openagents_web/live/components_live.ex
  • added test/openagents_web/components/graph_test.exs
  • modified test/openagents_web/icon_affordances_test.exs

Diff

7 files changed, +1302 -4

assets/css/openagents.css modified +166

@@ -1667,3 +1667,169 @@

1667 1667
    }
1668 1668
  }
1669 1669
}
1670
1671
/* ── Graph surface (SCV swarm) ────────────────────────────────────────────── */
1672
1673
/* Unit's visual language, expressed in the sanctioned palette. Status is
1674
 * carried by ring STYLE as well as hue -- hairline, heavy, dashed, doubled --
1675
 * so the taxonomy survives a colour-blind reader and a greyscale screenshot.
1676
 * See docs/2026-08-20-scv-swarm-visualization-unit-audit.md. */
1677
1678
@layer components {
1679
  .graph-surface {
1680
    width: 100%;
1681
    height: auto;
1682
    overflow: visible;
1683
    font-family: var(--font-sans);
1684
  }
1685
1686
  .graph-node__body {
1687
    fill: var(--ink-raised);
1688
    stroke: var(--line-strong);
1689
    stroke-width: 1;
1690
    transition:
1691
      fill var(--motion-fast) var(--ease),
1692
      stroke var(--motion-fast) var(--ease),
1693
      stroke-width var(--motion-fast) var(--ease);
1694
  }
1695
1696
  .graph-node__label {
1697
    fill: var(--text-muted);
1698
    font-size: 9px;
1699
    text-anchor: middle;
1700
    pointer-events: none;
1701
  }
1702
1703
  .graph-node[data-shape="circle"] {
1704
    cursor: pointer;
1705
  }
1706
1707
  /* SCV lifecycle. */
1708
  .graph-node[data-status="idle"] .graph-node__body {
1709
    stroke: var(--line);
1710
  }
1711
1712
  .graph-node[data-status="running"] .graph-node__body {
1713
    stroke: var(--accent);
1714
    stroke-width: 2;
1715
    fill: color-mix(in oklab, var(--accent) 12%, var(--ink-raised));
1716
  }
1717
1718
  .graph-node[data-status="paused"] .graph-node__body {
1719
    stroke: var(--warning);
1720
    stroke-dasharray: 3 3;
1721
  }
1722
1723
  .graph-node[data-status="circuit_open"] .graph-node__body {
1724
    stroke: var(--danger);
1725
    stroke-width: 2;
1726
    fill: color-mix(in oklab, var(--danger) 10%, var(--ink-raised));
1727
  }
1728
1729
  .graph-node[data-status="disabled"] .graph-node__body {
1730
    stroke: var(--line-faint);
1731
    fill: transparent;
1732
  }
1733
1734
  .graph-node[data-status="disabled"] .graph-node__label {
1735
    fill: var(--text-dim);
1736
  }
1737
1738
  /* Work-item lifecycle, on rect nodes. */
1739
  .graph-node[data-status="discovered"] .graph-node__body {
1740
    stroke: var(--line-soft);
1741
    stroke-dasharray: 2 2;
1742
    fill: transparent;
1743
  }
1744
1745
  .graph-node[data-status="admitted"] .graph-node__body {
1746
    stroke: var(--line-strong);
1747
  }
1748
1749
  .graph-node[data-status="completed"] .graph-node__body {
1750
    stroke: var(--success);
1751
    fill: color-mix(in oklab, var(--success) 12%, var(--ink-raised));
1752
  }
1753
1754
  .graph-node[data-status="deferred"] .graph-node__body {
1755
    stroke: var(--text-dim);
1756
    stroke-dasharray: 4 2;
1757
  }
1758
1759
  .graph-node[data-status="refused"] .graph-node__body {
1760
    stroke: var(--warning);
1761
  }
1762
1763
  .graph-node[data-status="failed"] .graph-node__body {
1764
    stroke: var(--danger);
1765
  }
1766
1767
  .graph-node--selected .graph-node__body {
1768
    stroke: var(--text-primary);
1769
    stroke-width: 2;
1770
  }
1771
1772
  /* Links. Three coincident paths: hit area, visible stroke, text anchor. */
1773
  .graph-link__hit {
1774
    stroke: transparent;
1775
    stroke-width: 12;
1776
    fill: none;
1777
    cursor: pointer;
1778
  }
1779
1780
  .graph-link__base {
1781
    stroke: var(--line-strong);
1782
    stroke-width: 1;
1783
    fill: none;
1784
  }
1785
1786
  .graph-link__text-path {
1787
    fill: none;
1788
    stroke: none;
1789
  }
1790
1791
  .graph-link__label {
1792
    fill: var(--text-dim);
1793
    font-size: 8px;
1794
    text-anchor: middle;
1795
  }
1796
1797
  .graph-link[data-kind="error"] .graph-link__base {
1798
    stroke: var(--danger);
1799
  }
1800
1801
  .graph-link[data-kind="data"] .graph-link__base {
1802
    stroke: var(--line);
1803
    stroke-dasharray: 2 2;
1804
  }
1805
1806
  .graph-link[data-kind="type"] .graph-link__base {
1807
    stroke: var(--line-soft);
1808
  }
1809
1810
  .graph-link[data-kind="exposed"] .graph-link__base {
1811
    stroke: var(--accent-bright);
1812
  }
1813
1814
  .graph-link--active .graph-link__base {
1815
    stroke: var(--accent);
1816
  }
1817
1818
  .graph-link__pulse {
1819
    fill: var(--accent);
1820
  }
1821
1822
  .graph-arrow {
1823
    fill: none;
1824
    stroke: var(--line-strong);
1825
    stroke-width: 1.5;
1826
  }
1827
1828
  /* A step pulse is motion, and motion is the one channel that should mean
1829
     something on this surface. Honour the reader's preference to stop it. */
1830
  @media (prefers-reduced-motion: reduce) {
1831
    .graph-link__pulse {
1832
      display: none;
1833
    }
1834
  }
1835
}
docs/2026-08-20-scv-swarm-visualization-unit-audit.md added +397

@@ -0,0 +1,397 @@

1
# SCV swarm visualization: porting Unit's visual language to Elixir
2
3
Date: 2026-08-20
4
5
Status: Proposed — audit and design, no implementation
6
7
Subject: how to render a live swarm of SCVs (15+ concurrently) in the visual
8
language of [Unit](https://github.com/samuelmtimbo/unit), implemented natively
9
in Elixir, Phoenix, HEEx, and LiveView rather than ported as TypeScript.
10
11
Reference read: `github.com/samuelmtimbo/unit` at revision `ea1b4d7e`, MIT
12
licensed (UNIT IO, Inc, 2021). 1,639 TypeScript files. Every line reference
13
below is to that revision; paths are repository-relative.
14
15
---
16
17
## 0. The claim
18
19
Unit's visual language is a good fit for the SCV swarm, and the reason is
20
structural rather than aesthetic.
21
22
Unit renders **MIMO finite state machines connected by typed links**. Its
23
README states that formally: "units are Multi Input Multi Output (MIMO) Finite
24
State Machines (FSM). A program in Unit is represented as a Graph."
25
26
An SCV is exactly that shape. `docs/scv-planning.md` defines an SCV as a durable
27
FSM with five states (`disabled`, `idle`, `running`, `paused`, `circuit_open`),
28
typed inputs (`scv_work_items`, themselves a state machine across `discovered`
29
→ `admitted` → `running` → `completed`/`deferred`/`refused`/`failed`), and typed
30
outputs (candidates handed to the Forge deployment pipeline). `scv_steps` is
31
specified to "store every provider and tool boundary in order" — an ordered
32
dataflow trace.
33
34
So a swarm view is not a metaphor laid over unrelated data. It is a faithful
35
rendering of the data model the SCV plan already specifies. That is the
36
difference between a visualization that stays true as the system evolves and a
37
dashboard that drifts into decoration.
38
39
**What we should take is the vocabulary, not the codebase.** §3 argues we
40
should deliberately *not* port Unit's force simulation, which is its single
41
largest visual subsystem.
42
43
## 1. Unit's visual language, distilled
44
45
Extracted from the source rather than the docs. The whole language is small,
46
which is what makes it portable.
47
48
### 1.1 Node shape carries type
49
50
`Editor/Component.ts:7468`:
51
52
```ts
53
borderRadius: is_component ? '0' : '50%'
54
```
55
56
**Units are circles. Components are squares.** One property, and the graph
57
becomes readable at a glance — you can see which nodes are live processes and
58
which are composed surfaces without reading a label. The `Shape` type is
59
exactly two values (`client/util/geometry/index.ts:575`):
60
61
```ts
62
export type Shape = 'circle' | 'rect'
63
```
64
65
### 1.2 Links attach to surfaces, not centers
66
67
This is the detail that makes Unit graphs look designed rather than generated.
68
A link is a straight segment between two points computed on the node
69
*boundaries* (`Editor/Component.ts:14188`):
70
71
```ts
72
const { x: x0, y: y0 } = pointInNode(source, u, padding_source)
73
const { x: x1, y: y1 } = pointInNode(target, nu, padding_target)
74
const path_d = `M ${x0} ${y0} L ${x1} ${y1}`
75
```
76
77
Naive graph renderers draw center-to-center and hide the overlap behind opaque
78
nodes. Unit computes the surface intersection, so link length encodes real
79
separation and the arrowhead sits flush against the node.
80
81
The geometry module supplies the primitives: `pointInNode`, `surfaceDistance`,
82
`centerToSurfaceDistance`, `unitVector`, `describeArc`, `describeCircle`,
83
`describeRect`, `describeArrowPolygon`, `catmullRomSpline`.
84
85
### 1.3 Arrowheads conform to the surface they touch
86
87
`Editor/Component.ts:856`:
88
89
```ts
90
export const describeArrowShape = (shape: Shape, r: number): string => {
91
  if (shape === 'circle') return describeArrowSemicircle(r)
92
  else return ARROW_FLAT
93
}
94
```
95
96
A link into a circle terminates in an arc that hugs the circumference; a link
97
into a rect terminates in a flat bar. The marker alphabet is four strings
98
(`Editor/Component.ts:925`):
99
100
```ts
101
export const ARROW_NONE   = ''
102
export const ARROW_MEMORY = 'M-6,4 L0,1 L-6,-2'
103
export const ARROW_NORMAL = 'M-0.25,2.25 L2,1 L-0.25,-0.25'
104
export const ARROW_FLAT   = 'M0,8 L0,-5.5'
105
```
106
107
Four path strings and one conditional carry the entire edge-termination
108
vocabulary. That is the level of economy worth copying.
109
110
### 1.4 Link labels ride the link
111
112
`Editor/Component.ts:14194` sets the same `d` on three overlaid paths: the
113
visible stroke, a wider invisible `link_base_area` for hit-testing, and
114
`link_base_text` used as a `textPath` anchor. When a link runs right-to-left the
115
component swaps in the inverted path so the label never renders upside down, and
116
flips the markers with `transform: scaleX(-1)`.
117
118
Three paths per link, one geometry calculation. The hit area being a separate
119
wider path is why Unit graphs are pleasant to click at any zoom.
120
121
### 1.5 Distance is typed
122
123
`src/constant/LINK_DISTANCE.ts` — link length is a semantic property, not a
124
layout constant:
125
126
```ts
127
export const LINK_DISTANCE          = 24
128
export const LINK_DISTANCE_TYPE     = LINK_DISTANCE / 2      // 12
129
export const LINK_DISTANCE_DATA     = LINK_DISTANCE / 2      // 12
130
export const LINK_DISTANCE_ERR      = LINK_DISTANCE * 7 / 8  // 21
131
export const LINK_DISTANCE_EXPOSED  = LINK_DISTANCE * 2 / 3  // 16
132
```
133
134
Type relationships sit closer than dataflow; errors sit closer than normal
135
links. Proximity encodes kind.
136
137
### 1.6 Layers, not z-index soup
138
139
Ten named layers (`Editor/Component.ts`): `LAYER_NORMAL`, `LAYER_COLLAPSE`,
140
`LAYER_SEARCH`, `LAYER_IGNORED`, `LAYER_EXPOSED`, `LAYER_DATA_LINKED`,
141
`LAYER_DATA`, `LAYER_ERR`, `LAYER_TYPE`, with
142
`LAYER_OPACITY_MULTIPLIER = 0.1`. Depth is a small enum and opacity is derived
143
from it, so nothing competes for arbitrary stacking values.
144
145
### 1.7 The palette is greyscale-first
146
147
`client/theme.ts` is a twelve-rung greyscale ramp (`COLOR_GRAYSCALE_BASE00`
148
`#FCFCFC` through `BASE11` `#080808`) plus a handful of named accents, each with
149
a paired darker "link" variant:
150
151
```ts
152
COLOR_RED   = '#ff6666'   COLOR_LINK_RED   = '#ff4d4d'
153
COLOR_GREEN = '#00aa11'   COLOR_LINK_GREEN = '#0b8e14'
154
COLOR_BLUE  = '#0066ff'   COLOR_LINK_BLUE  = '#1d62c9'
155
```
156
157
**This is directly compatible with the palette this repo just adopted.**
158
`assets/css/app.css` now carries Linear's neutral ramp (`#08090a` → `#f7f8f8`)
159
with a single indigo accent. Unit's structure — a long neutral ramp plus sparse
160
semantic accents, with links a shade darker than fills — maps onto our existing
161
tokens without introducing a third palette, which `UI-003` forbids
162
(`INVARIANTS.md:1520`: "not introduce a third palette").
163
164
The port should use `--ink-*`, `--text-*`, `--line-*`, `--wash-*` and the
165
existing `--success` / `--warning` / `--danger` / `--accent`, never Unit's hex
166
literals.
167
168
## 2. Mapping SCV state onto the language
169
170
| SCV concept | Visual |
171
| --- | --- |
172
| One SCV | Circle node. It is a live FSM, so it takes the circle. |
173
| Work item | Rect node. Composed, inert data, not a running process. |
174
| Candidate / Forge handoff | Rect node terminating the chain. |
175
| SCV status | Ring treatment: `idle` hairline, `running` accent ring, `paused` dashed, `circuit_open` danger ring, `disabled` faint. |
176
| Run phase | Node fill wash, stepped through `--wash-hover` → `--wash-selected`. |
177
| `scv_steps` | Pulse travelling along the outbound link, one per boundary. |
178
| Tool call vs provider call | Link kind, hence link distance (§1.5) and marker (§1.3). |
179
| Error / `error_event_count` | `LAYER_ERR` equivalent: danger stroke, shorter link distance so failures visibly cluster. |
180
| Token / cost / CPU usage | Node radius, bounded. Cheap runs stay small; expensive ones are visibly large. |
181
| Repository scope | Spatial grouping — one cluster per repository. |
182
183
The 15-agent view the request asks for is then: fifteen circles, each with a
184
short outbound chain to its current work item and candidate, sized by spend,
185
ringed by status, pulsing on each step. Clicking a circle drills into that
186
SCV's run.
187
188
## 3. Do not port the force simulation
189
190
`client/simulation.ts` is 355 self-contained lines implementing a Runge-Kutta
191
integrated force layout (RK1 through RK4, selectable via a `stability` option),
192
with no d3 dependency. It is genuinely nice code and it would port to Elixir
193
cleanly.
194
195
**We should still not use it, and this is the main design opinion in this
196
document.**
197
198
Unit needs a force simulation because its graphs are user-authored and
199
arbitrary: an author drops units anywhere and the simulation finds a readable
200
arrangement. Our graph is not arbitrary. The SCV pipeline has a known shape —
201
work item → SCV → run → candidate → Forge — which is a staged DAG with a fixed
202
number of stages.
203
204
For a known DAG, a force simulation is strictly worse:
205
206
- **It moves when nothing meaningful changed.** An operator watching 15 agents
207
  needs to notice *state* changes. A layout that drifts on every tick trains
208
  them to ignore motion, which is the one channel that should mean something.
209
- **Position stops being an identifier.** With a stable layout, "the third one
210
  down is stuck again" is a real observation. With a simulation, node identity
211
  has to be re-read from labels every time.
212
- **It costs continuously.** A simulation must tick to converge. A staged layout
213
  is computed once per topology change.
214
215
Use a deterministic staged layout: column per pipeline stage, stable vertical
216
ordering within a column (by SCV id, never by a mutable field like score, or
217
rows will swap under the operator's cursor). Keep Unit's *geometry* — surface
218
anchoring, conforming arrowheads, typed distances — which is what actually makes
219
it look like Unit.
220
221
Keep the simulation in mind for one later case: a free "swarm" view where
222
clustering by repository or failure mode is the point and exact position is not.
223
If we build that, port `simulation.ts` then, with measurements.
224
225
## 4. Elixir and Phoenix implementation
226
227
### 4.1 Render SVG server-side in HEEx
228
229
The geometry is arithmetic. Elixir does arithmetic. There is no reason for a
230
client-side graph library.
231
232
```elixir
233
defmodule OpenAgentsWeb.UI.Graph do
234
  @moduledoc """
235
  SVG graph primitives in Unit's visual language: circular unit nodes,
236
  rectangular data nodes, and links anchored to node surfaces with
237
  shape-conforming terminations.
238
239
  Ported from Unit (https://github.com/samuelmtimbo/unit), MIT licensed,
240
  UNIT IO Inc. Geometry reimplemented in Elixir; no Unit code is vendored.
241
  """
242
243
  @pin_radius 5
244
  @link_distance 24
245
246
  @doc "The point where a link meets a node's surface, per Unit's pointInNode."
247
  def surface_point(%{shape: :circle, x: x, y: y, r: r}, {ux, uy}, padding) do
248
    {x + ux * (r + padding), y + uy * (r + padding)}
249
  end
250
251
  def surface_point(%{shape: :rect} = node, {ux, uy}, padding) do
252
    # rect surface intersection along the unit vector
253
  end
254
255
  def unit_vector({x0, y0}, {x1, y1}) do
256
    dx = x1 - x0
257
    dy = y1 - y0
258
    case :math.sqrt(dx * dx + dy * dy) do
259
      0.0 -> {0.0, 0.0}
260
      d -> {dx / d, dy / d}
261
    end
262
  end
263
end
264
```
265
266
Nodes and links become function components rendering `<circle>`, `<rect>`,
267
`<path>`. State changes are ordinary LiveView assigns; Phoenix sends attribute
268
diffs, and CSS transitions animate them. For fifteen nodes this is far less data
269
over the wire than a JS graph library's state sync.
270
271
### 4.2 Animate in CSS, not in a render loop
272
273
Ring colour, fill wash, and radius are attributes. Give them a transition in the
274
style pack and every state change animates for free. Step pulses are a
275
`<circle>` on a `<path>` with SMIL `<animateMotion>`, or a CSS
276
`offset-path` animation — no JS, no timer.
277
278
This matters for a 15-agent view: a render loop for 15 animated nodes is a
279
render loop you pay for even when nothing is happening. Declarative animation
280
costs nothing while idle.
281
282
### 4.3 Reach for a colocated hook only for pan and zoom
283
284
Pan and zoom are pointer-rate interactions and must not round-trip. That is a
285
small colocated hook mutating one `viewBox` attribute — the pattern already used
286
in `chat_live.ex` (`phx-hook=".SidebarShell"`, `phx-hook=".TranscriptScroll"`).
287
Unit's `ZOOM_INTENSITY = 0.05` is a good starting constant.
288
289
Everything else — selection, drill-down, filtering — is a LiveView event.
290
291
### 4.4 The invariant constraints this must satisfy
292
293
- **`UI-003`** (`INVARIANTS.md:1499`): product surfaces render only through the
294
  sanctioned component library, with no third palette. So graph primitives go in
295
  the governed component module (`OpenAgentsWeb.UI` in
296
  `lib/openagents_web/components/ui.ex`, or a `UI.Graph` submodule beside it), styled from existing tokens.
297
- **The component catalog is enforced by test.** `ComponentCatalogTest` walks
298
  `__components__/0` for every documented module and fails when a public
299
  function component has no catalog entry. Every new graph primitive needs a
300
  `ComponentCatalog` entry and a `component_demo/1` clause, or the suite fails.
301
  That is a feature — the catalog stays honest — but it is work to budget.
302
- **No hosted CI** (`RELEASE-004`). Tests run on owned infrastructure.
303
- **Basecoat components are imported individually**; never
304
  `basecoat.css`/`basecoat-base.css`/`basecoat-components.css`.
305
306
### 4.5 Licensing
307
308
Unit is MIT (UNIT IO, Inc, 2021), which is compatible with this repo's AGPL-3.0.
309
The workspace contract says reference repos are read-only and "do not vendor or
310
copy large chunks of external code into our repos by default."
311
312
So: **reimplement the geometry in Elixir, attribute Unit in the module docs, do
313
not vendor TypeScript.** The values worth carrying verbatim are constants
314
(`PIN_RADIUS = 5`, `LINK_DISTANCE = 24` and its typed variants,
315
`ZOOM_INTENSITY = 0.05`) and the four arrow path strings, all of which are facts
316
about a visual language rather than substantial code.
317
318
## 5. What exists today
319
320
Honest current state, verified at `d247a8a`:
321
322
**The SCV runtime exists in embryo — 647 lines total:**
323
324
| module | lines |
325
| --- | --- |
326
| `lib/openagents/scv/worker.ex` | 213 |
327
| `lib/openagents/scv/open_code_events.ex` | 164 |
328
| `lib/openagents/scv/run.ex` | 146 |
329
| `lib/openagents/scv/environment.ex` | 42 |
330
| `lib/openagents/scv/resource_sampler.ex` | 33 |
331
| `lib/openagents/scv.ex` | 21 |
332
| `lib/openagents/scv/driver.ex`, `runner.ex` | 28 |
333
334
**There is no SCV web surface at all.** Nothing under `lib/openagents_web/`
335
references SCV. This proposal is the first.
336
337
**The durable tables are specified but unbuilt.** `docs/scv-planning.md`
338
specifies `scvs`, `scv_work_items`, `scv_runs`, `scv_steps`, and
339
`scv_worker_images`. None exist as migrations. `SCV.Run` is a plain struct, not
340
an Ecto schema.
341
342
**So bind to what is real today**, and widen later:
343
344
- `OpenAgents.SCV.OpenCodeEvents.summary/1` already returns `event_count`,
345
  `event_types`, `tool_calls`, `tool_outcomes`, `usage`, `error_event_count`,
346
  and `session_ids`. That is enough to drive node fill, radius, error state, and
347
  a step counter for a single run **now**.
348
- `OpenAgents.SCV.ResourceSampler.sample/1` supplies resource figures for radius.
349
- A multi-SCV view needs the `scvs` and `scv_runs` tables. Until they exist,
350
  a swarm view can only show runs the node itself is supervising — real, but not
351
  the fleet-wide picture the 15-agent view implies.
352
353
This ordering matters. Building the visualization against the planned schema
354
before the schema exists would produce a component that renders fixtures and
355
nothing else.
356
357
## 6. Suggested sequence
358
359
1. **`UI.Graph` geometry module + tests.** Pure functions: `unit_vector/2`,
360
   `surface_point/3` for circle and rect, `arrow_path/2`, `describe_arc/5`.
361
   Property-tested — a link must terminate on the boundary for every angle, and
362
   that is exactly the kind of thing property tests are good at.
363
2. **Static node and link components**, catalogued per §4.4, demoed with
364
   fixtures.
365
3. **Single-run view** bound to `OpenCodeEvents.summary/1` and
366
   `ResourceSampler.sample/1` — real data, one SCV, no new schema required.
367
4. **`scvs` / `scv_runs` schema** per the SCV plan, whenever that work is
368
   scheduled on its own merits.
369
5. **Swarm view** over that schema: N circles, staged layout, status rings.
370
6. **Pan/zoom hook**, only once the static view is worth navigating.
371
7. **Drill-down**: click an SCV → its run → its steps.
372
373
Steps 1–3 deliver something real without depending on unbuilt schema. Step 5 is
374
the deliverable the request describes, and it is gated on step 4, which is a
375
separate decision.
376
377
## 7. Risks and open questions
378
379
- **Fifteen is not the hard case; a hundred is.** A staged layout degrades
380
  gracefully to a scrollable column set; a force simulation degrades to a hairball.
381
  Another argument for §3, but the swarm view should still declare its bound.
382
- **Step pulses could become noise.** If every provider and tool boundary emits a
383
  visible pulse, a busy SCV is a strobe. Rate-limit at the projection, not in
384
  CSS — one pulse per N steps with a count, in the spirit of `LAYER_OPACITY_MULTIPLIER`.
385
- **`scv_steps` volume.** The plan says steps store "every provider and tool
386
  boundary in order." Fifteen concurrent SCVs will produce a high-rate stream. The
387
  swarm view must subscribe to a bounded projection, never the raw step table —
388
  the same rule `UI-002` already applies to tool activity.
389
- **Unit's language is monochrome-leaning by design.** It survives on shape,
390
  proximity, and opacity, using colour sparingly. That suits our palette but means
391
  status must be legible without colour — hence ring *style* (hairline, dashed,
392
  heavy) carrying status alongside hue, which also makes it colour-blind safe.
393
- **Naming.** Unit calls its nodes "units". We already use "unit" for other
394
  things and `SCV` is a fixed term per the SCV plan ("Use SCV consistently in
395
  code, documentation, configuration, and the interface. Do not introduce another
396
  name for this subsystem."). Call the primitives `graph_node` / `graph_link`,
397
  never `unit`.
lib/openagents_web/component_catalog.ex modified +31

@@ -191,6 +191,33 @@ defmodule OpenAgentsWeb.ComponentCatalog do

191 191
        }
192 192
      ]
193 193
    },
194
    %{
195
      title: "SCV graph",
196
      items: [
197
        %{
198
          slug: "graph-node",
199
          title: "Graph node",
200
          icon: "circle",
201
          source: "OpenAgentsWeb.UI.Graph.graph_node/1",
202
          summary:
203
            "Circle for a live SCV, rect for inert data; ring style carries lifecycle state."
204
        },
205
        %{
206
          slug: "graph-link",
207
          title: "Graph link",
208
          icon: "compass",
209
          source: "OpenAgentsWeb.UI.Graph.graph_link/1",
210
          summary: "Surface-anchored link with a shape-conforming termination and a step pulse."
211
        },
212
        %{
213
          slug: "scv-swarm",
214
          title: "SCV swarm",
215
          icon: "grid",
216
          source: "OpenAgentsWeb.UI.Graph.scv_swarm/1",
217
          summary: "Many SCVs at once in a deterministic staged layout."
218
        }
219
      ]
220
    },
194 221
    %{
195 222
      title: "Forge",
196 223
      items: [

@@ -222,6 +249,10 @@ defmodule OpenAgentsWeb.ComponentCatalog do

222 249
    %{
223 250
      OpenAgentsWeb.UI => [],
224 251
      OpenAgentsWeb.Layouts => [:app, :flash_group],
252
      # graph_defs/1 emits marker definitions into a parent graph surface; it
253
      # renders nothing on its own, so it has no demoable page. graph_surface/1
254
      # is the host element and is demoed through the components that use it.
255
      OpenAgentsWeb.UI.Graph => [:graph_defs, :graph_surface],
225 256
      OpenAgentsWeb.Components.RepoHeader => []
226 257
    }
227 258
  end
lib/openagents_web/components/graph.ex added +389

@@ -0,0 +1,389 @@

1
defmodule OpenAgentsWeb.UI.Graph do
2
  @moduledoc """
3
  SVG graph primitives for rendering live SCV work as a dataflow graph.
4
5
  The visual language is ported from [Unit](https://github.com/samuelmtimbo/unit)
6
  (MIT, UNIT IO Inc). The geometry is reimplemented in Elixir; no Unit code is
7
  vendored. The design rationale and the source citations for each rule live in
8
  `docs/2026-08-20-scv-swarm-visualization-unit-audit.md`.
9
10
  Four rules carry the whole language:
11
12
    1. **Shape carries kind.** A circle is a live finite state machine — an SCV.
13
       A rect is inert composed data — a work item, a candidate. You can read
14
       what a node *is* without reading its label.
15
    2. **Links meet surfaces, not centers.** A link terminates on the node
16
       boundary, so its drawn length is the real separation between two nodes
17
       rather than a segment hidden under an opaque fill.
18
    3. **Terminations conform to the surface they touch.** An arrow into a
19
       circle is an arc struck on that circle's circumference; an arrow into a
20
       rect is a flat bar.
21
    4. **Proximity encodes kind.** Link distance is a property of the link's
22
       type, not a global layout constant, so related things sit closer.
23
24
  Colour comes only from the sanctioned palette tokens (`UI-003`); no hex
25
  literal from Unit's theme appears here. Status is expressed by ring *style*
26
  as well as hue so the taxonomy stays legible without colour.
27
  """
28
29
  use Phoenix.Component
30
31
  # Unit's constants, carried verbatim as facts about the visual language.
32
  # src/constant/PIN_RADIUS.ts and src/constant/LINK_DISTANCE.ts.
33
  @pin_radius 5
34
  @link_distance 24
35
36
  @doc "Base link distance in user units. Typed variants derive from it."
37
  def link_distance, do: @link_distance
38
39
  @doc "Pin radius in user units."
40
  def pin_radius, do: @pin_radius
41
42
  @doc """
43
  Link distance for a link kind.
44
45
  Type and data relationships sit at half distance; errors sit slightly closer
46
  than a normal link so failures visibly cluster; exposed links sit at
47
  two-thirds. Same ratios as Unit's `LINK_DISTANCE.ts`.
48
  """
49
  def link_distance(:type), do: @link_distance / 2
50
  def link_distance(:data), do: @link_distance / 2
51
  def link_distance(:error), do: @link_distance * 7 / 8
52
  def link_distance(:exposed), do: @link_distance * 2 / 3
53
  def link_distance(_normal), do: @link_distance
54
55
  @doc """
56
  The unit vector from one point to another.
57
58
  Returns `{0.0, 0.0}` for coincident points rather than dividing by zero — two
59
  nodes at the same position have no meaningful direction between them.
60
  """
61
  def unit_vector({x0, y0}, {x1, y1}) do
62
    dx = x1 - x0
63
    dy = y1 - y0
64
65
    d = :math.sqrt(dx * dx + dy * dy)
66
67
    # `== 0` rather than a `0.0` pattern: it covers both signed zeros without
68
    # asserting which one sqrt produced.
69
    if d == 0, do: {0.0, 0.0}, else: {dx / d, dy / d}
70
  end
71
72
  @doc """
73
  The point where a link leaves a node's surface, travelling along `vector`.
74
75
  This is Unit's `pointInNode`. For a circle it is the centre pushed out by the
76
  radius; for a rect it is the intersection with whichever edge the vector
77
  actually crosses, found by scaling the vector to the nearer axis bound.
78
  """
79
  def surface_point(node, vector, padding \\ 0)
80
81
  def surface_point(%{shape: :circle, x: x, y: y, r: r}, {ux, uy}, padding) do
82
    {x + ux * (r + padding), y + uy * (r + padding)}
83
  end
84
85
  def surface_point(%{shape: :rect, x: x, y: y, width: w, height: h}, {ux, uy}, padding) do
86
    hw = w / 2 + padding
87
    hh = h / 2 + padding
88
89
    # Scale the unit vector until it first crosses a bound. The smaller ratio is
90
    # the edge it actually leaves through.
91
    tx = if ux == 0, do: :infinity, else: abs(hw / ux)
92
    ty = if uy == 0, do: :infinity, else: abs(hh / uy)
93
    t = min(tx, ty)
94
95
    case t do
96
      :infinity -> {x, y}
97
      t -> {x + ux * t, y + uy * t}
98
    end
99
  end
100
101
  @doc """
102
  An SVG arc path, used for circle-conforming arrow terminations.
103
104
  Angles are in degrees, measured as Unit measures them.
105
  """
106
  def describe_arc(cx, cy, r, start_angle, end_angle) do
107
    {sx, sy} = polar_to_cartesian(cx, cy, r, end_angle)
108
    {ex, ey} = polar_to_cartesian(cx, cy, r, start_angle)
109
    large_arc = if end_angle - start_angle <= 180, do: "0", else: "1"
110
111
    "M #{f(sx)} #{f(sy)} A #{f(r)} #{f(r)} 0 #{large_arc} 0 #{f(ex)} #{f(ey)}"
112
  end
113
114
  defp polar_to_cartesian(cx, cy, r, angle_degrees) do
115
    rad = (angle_degrees - 90) * :math.pi() / 180.0
116
    {cx + r * :math.cos(rad), cy + r * :math.sin(rad)}
117
  end
118
119
  defp f(n) when is_float(n), do: Float.round(n, 2)
120
  defp f(n), do: n
121
122
  @statuses ~w(idle running paused circuit_open disabled)a
123
  @item_statuses ~w(discovered admitted running completed deferred refused failed)a
124
  @link_kinds ~w(normal type data error exposed)a
125
126
  @doc "Every SCV status this component renders, in lifecycle order."
127
  def statuses, do: @statuses
128
129
  @doc "Every work-item status this component renders, in lifecycle order."
130
  def item_statuses, do: @item_statuses
131
132
  @doc "Every link kind this component renders."
133
  def link_kinds, do: @link_kinds
134
135
  @doc """
136
  One node in the graph.
137
138
  A `:circle` is a live SCV; a `:rect` is a work item or candidate. `status`
139
  drives the ring treatment and is validated against the SCV or work-item
140
  lifecycle depending on shape, so an impossible state cannot be rendered.
141
  """
142
  attr :id, :string, required: true
143
  attr :shape, :atom, values: [:circle, :rect], default: :circle
144
  attr :x, :float, required: true
145
  attr :y, :float, required: true
146
  attr :r, :float, default: 22.0, doc: "radius for a circle node"
147
  attr :width, :float, default: 44.0, doc: "width for a rect node"
148
  attr :height, :float, default: 32.0, doc: "height for a rect node"
149
  attr :status, :atom, default: :idle
150
  attr :label, :string, default: nil
151
  attr :selected, :boolean, default: false
152
  attr :class, :any, default: nil
153
  attr :rest, :global
154
155
  def graph_node(assigns) do
156
    ~H"""
157
    <g
158
      class={["graph-node", @selected && "graph-node--selected", @class]}
159
      data-status={@status}
160
      data-shape={@shape}
161
      role="img"
162
      aria-label={@label || @id}
163
      {@rest}
164
    >
165
      <circle :if={@shape == :circle} class="graph-node__body" cx={@x} cy={@y} r={@r} />
166
      <rect
167
        :if={@shape == :rect}
168
        class="graph-node__body"
169
        x={@x - @width / 2}
170
        y={@y - @height / 2}
171
        width={@width}
172
        height={@height}
173
        rx="2"
174
      />
175
      <text :if={@label} class="graph-node__label" x={@x} y={@y + label_offset(@shape, @r, @height)}>
176
        {@label}
177
      </text>
178
    </g>
179
    """
180
  end
181
182
  defp label_offset(:circle, r, _h), do: r + 12
183
  defp label_offset(:rect, _r, h), do: h / 2 + 12
184
185
  @doc """
186
  A link between two nodes, anchored to both surfaces.
187
188
  Renders three coincident paths, as Unit does: the visible stroke, a wider
189
  transparent hit area, and a text anchor. The hit area is why a one-pixel link
190
  is still clickable.
191
  """
192
  attr :id, :string, required: true
193
  attr :source, :map, required: true, doc: "node map with shape, x, y and r or width/height"
194
  attr :target, :map, required: true
195
  attr :kind, :atom, values: @link_kinds, default: :normal
196
  attr :active, :boolean, default: false, doc: "a step is currently traversing this link"
197
  attr :label, :string, default: nil
198
  attr :prefix, :string, default: "graph", doc: "marker namespace; must match graph_defs/1"
199
  attr :class, :any, default: nil
200
201
  def graph_link(assigns) do
202
    u = unit_vector({assigns.source.x, assigns.source.y}, {assigns.target.x, assigns.target.y})
203
    {nux, nuy} = u
204
    {x0, y0} = surface_point(assigns.source, u, 1)
205
    {x1, y1} = surface_point(assigns.target, {-nux, -nuy}, 1)
206
207
    assigns =
208
      assigns
209
      |> assign(:d, "M #{f(x0)} #{f(y0)} L #{f(x1)} #{f(y1)}")
210
      |> assign(:marker, "#{assigns.prefix}-#{marker_id(assigns.target)}")
211
212
    ~H"""
213
    <g class={["graph-link", @active && "graph-link--active", @class]} data-kind={@kind}>
214
      <path class="graph-link__hit" d={@d} />
215
      <path class="graph-link__base" d={@d} marker-end={"url(##{@marker})"} />
216
      <path :if={@label} id={"#{@id}-text"} class="graph-link__text-path" d={@d} />
217
      <text :if={@label} class="graph-link__label">
218
        <textPath href={"##{@id}-text"} startOffset="50%">{@label}</textPath>
219
      </text>
220
      <circle :if={@active} class="graph-link__pulse" r={pin_radius() / 2}>
221
        <animateMotion dur="1.1s" repeatCount="indefinite" path={@d} />
222
      </circle>
223
    </g>
224
    """
225
  end
226
227
  defp marker_id(%{shape: :circle}), do: "arrow-semicircle"
228
  defp marker_id(_rect), do: "arrow-flat"
229
230
  @doc """
231
  The marker and filter definitions every graph surface needs.
232
233
  SVG marker references resolve document-wide, not per `<svg>`, so one instance
234
  per page is normally enough. `prefix` exists so two independent graph surfaces
235
  on the same page can each own their markers rather than colliding — the
236
  failure mode a component with hardcoded ids always eventually hits.
237
  """
238
  attr :prefix, :string, default: "graph", doc: "marker namespace; must match graph_link/1"
239
240
  def graph_defs(assigns) do
241
    assigns = assign(assigns, :semicircle, describe_arc(6.0, 6.0, 5.0, 210, 330))
242
243
    ~H"""
244
    <defs>
245
      <marker
246
        id={"#{@prefix}-arrow-semicircle"}
247
        viewBox="0 0 12 12"
248
        refX="11"
249
        refY="6"
250
        markerWidth="7"
251
        markerHeight="7"
252
        orient="auto-start-reverse"
253
      >
254
        <path class="graph-arrow" d={@semicircle} />
255
      </marker>
256
      <marker
257
        id={"#{@prefix}-arrow-flat"}
258
        viewBox="0 0 12 12"
259
        refX="11"
260
        refY="6"
261
        markerWidth="7"
262
        markerHeight="7"
263
        orient="auto-start-reverse"
264
      >
265
        <path class="graph-arrow" d="M 11 1 L 11 11" />
266
      </marker>
267
    </defs>
268
    """
269
  end
270
271
  @doc """
272
  The host element for a graph.
273
274
  Callers should not hand-write the root element: it carries the marker
275
  definitions, the viewBox, and the accessible name, and getting any of those
276
  wrong is silent. `graph_surface/1` is also why no other module needs inline
277
  vector markup.
278
  """
279
  attr :id, :string, default: nil
280
  attr :view_box, :string, required: true
281
  attr :label, :string, required: true
282
  attr :prefix, :string, default: "graph"
283
  attr :class, :any, default: nil
284
  slot :inner_block, required: true
285
286
  def graph_surface(assigns) do
287
    ~H"""
288
    <svg
289
      id={@id}
290
      class={["graph-surface", @class]}
291
      viewBox={@view_box}
292
      role="img"
293
      aria-label={@label}
294
    >
295
      <.graph_defs prefix={@prefix} />
296
      {render_slot(@inner_block)}
297
    </svg>
298
    """
299
  end
300
301
  @doc """
302
  A swarm of SCVs in a deterministic staged layout.
303
304
  Position is stable and derived from the index, never from a mutable field
305
  like score — an operator watching fifteen agents needs "the third one down"
306
  to keep meaning the same agent. The audit argues at length for why this is a
307
  staged layout rather than Unit's force simulation.
308
309
  Each entry is a map with `:id`, `:status`, and optionally `:label`, `:weight`
310
  (0.0–1.0, scales the radius by spend) and `:item_status`.
311
  """
312
  attr :id, :string, default: "scv-swarm"
313
  attr :scvs, :list, required: true
314
  attr :columns, :integer, default: 5
315
  attr :selected_id, :string, default: nil
316
  attr :class, :any, default: nil
317
318
  def scv_swarm(assigns) do
319
    assigns = assign(assigns, :placed, place(assigns.scvs, assigns.columns))
320
321
    ~H"""
322
    <svg
323
      id={@id}
324
      class={["graph-surface", @class]}
325
      viewBox={"0 0 #{@columns * 96} #{ceil(length(@scvs) / @columns) * 96}"}
326
      role="group"
327
      aria-label={"#{length(@scvs)} SCVs"}
328
    >
329
      <.graph_defs />
330
      <.graph_link
331
        :for={p <- @placed}
332
        :if={p.item}
333
        id={"#{p.scv.id}-link"}
334
        source={p.node}
335
        target={p.item}
336
        kind={link_kind_for(p.scv)}
337
        active={p.scv.status == :running}
338
      />
339
      <.graph_node
340
        :for={p <- @placed}
341
        :if={p.item}
342
        id={"#{p.scv.id}-item"}
343
        shape={:rect}
344
        x={p.item.x}
345
        y={p.item.y}
346
        width={p.item.width}
347
        height={p.item.height}
348
        status={Map.get(p.scv, :item_status, :discovered)}
349
      />
350
      <.graph_node
351
        :for={p <- @placed}
352
        id={p.scv.id}
353
        shape={:circle}
354
        x={p.node.x}
355
        y={p.node.y}
356
        r={p.node.r}
357
        status={p.scv.status}
358
        label={Map.get(p.scv, :label)}
359
        selected={p.scv.id == @selected_id}
360
        phx-click="select_scv"
361
        phx-value-id={p.scv.id}
362
      />
363
    </svg>
364
    """
365
  end
366
367
  defp link_kind_for(%{status: :circuit_open}), do: :error
368
  defp link_kind_for(_), do: :normal
369
370
  # Deterministic placement: column-major grid, ordered by the caller's order.
371
  defp place(scvs, columns) do
372
    scvs
373
    |> Enum.with_index()
374
    |> Enum.map(fn {scv, i} ->
375
      cx = rem(i, columns) * 96 + 34.0
376
      cy = div(i, columns) * 96 + 34.0
377
      weight = Map.get(scv, :weight, 0.0)
378
379
      node = %{shape: :circle, x: cx, y: cy, r: 14.0 + weight * 10.0}
380
381
      item =
382
        if scv.status in [:running, :paused] do
383
          %{shape: :rect, x: cx + 46.0, y: cy, width: 22.0, height: 16.0}
384
        end
385
386
      %{scv: scv, node: node, item: item}
387
    end)
388
  end
389
end
lib/openagents_web/live/components_live.ex modified +190 -1

@@ -12,6 +12,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

12 12
  use OpenAgentsWeb, :live_view
13 13
14 14
  alias OpenAgentsWeb.ComponentCatalog
15
  alias OpenAgentsWeb.UI.Graph
15 16
  alias OpenAgentsWeb.UI, as: UI
16 17
17 18
  @sample_rows [

@@ -32,6 +33,26 @@ defmodule OpenAgentsWeb.ComponentsLive do

32 33
    github_avatar_url: nil
33 34
  }
34 35
36
  # Fifteen SCVs spanning every status, so the swarm page is the taxonomy in
37
  # aggregate rather than a happy-path screenshot.
38
  @demo_swarm [
39
    %{id: "scv-01", status: :running, label: "01", weight: 0.9, item_status: :running},
40
    %{id: "scv-02", status: :running, label: "02", weight: 0.3, item_status: :running},
41
    %{id: "scv-03", status: :idle, label: "03", weight: 0.0},
42
    %{id: "scv-04", status: :running, label: "04", weight: 0.6, item_status: :admitted},
43
    %{id: "scv-05", status: :paused, label: "05", weight: 0.4, item_status: :deferred},
44
    %{id: "scv-06", status: :idle, label: "06", weight: 0.0},
45
    %{id: "scv-07", status: :circuit_open, label: "07", weight: 0.2},
46
    %{id: "scv-08", status: :running, label: "08", weight: 1.0, item_status: :running},
47
    %{id: "scv-09", status: :disabled, label: "09", weight: 0.0},
48
    %{id: "scv-10", status: :running, label: "10", weight: 0.5, item_status: :completed},
49
    %{id: "scv-11", status: :idle, label: "11", weight: 0.0},
50
    %{id: "scv-12", status: :paused, label: "12", weight: 0.7, item_status: :refused},
51
    %{id: "scv-13", status: :running, label: "13", weight: 0.15, item_status: :failed},
52
    %{id: "scv-14", status: :disabled, label: "14", weight: 0.0},
53
    %{id: "scv-15", status: :running, label: "15", weight: 0.8, item_status: :discovered}
54
  ]
55
35 56
  @impl true
36 57
  def mount(_params, _session, socket) do
37 58
    form =

@@ -50,7 +71,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

50 71
     |> assign(:form, form)
51 72
     |> assign(:rows, @sample_rows)
52 73
     |> assign(:openagents_icons, @openagents_icons)
53
     |> assign(:demo_user, @demo_user)}
74
     |> assign(:demo_user, @demo_user)
75
     |> assign(:demo_swarm, @demo_swarm)}
54 76
  end
55 77
56 78
  @impl true

@@ -501,4 +523,171 @@ defmodule OpenAgentsWeb.ComponentsLive do

501 523
    </div>
502 524
    """
503 525
  end
526
527
  # --- SCV graph -----------------------------------------------------------
528
  # The full state taxonomy is rendered here rather than described, so the
529
  # catalog page is the executable inventory of what each state looks like.
530
531
  defp component_demo(%{item: %{slug: "graph-node"}} = assigns) do
532
    ~H"""
533
    <div class="space-y-6">
534
      <div class="space-y-2">
535
        <p class="text-sm text-base-content/60">
536
          SCV lifecycle — circle nodes. Ring style carries the state as well as hue.
537
        </p>
538
        <Graph.graph_surface view_box="0 0 460 78" label="SCV statuses" prefix="demo-scv">
539
          <Graph.graph_node
540
            :for={{status, i} <- Enum.with_index(Graph.statuses())}
541
            id={"demo-scv-#{status}"}
542
            shape={:circle}
543
            x={40.0 + i * 92}
544
            y={30.0}
545
            r={18.0}
546
            status={status}
547
            label={to_string(status)}
548
          />
549
        </Graph.graph_surface>
550
      </div>
551
552
      <div class="space-y-2">
553
        <p class="text-sm text-base-content/60">
554
          Work-item lifecycle — rect nodes, because a work item is inert data, not a
555
          running machine.
556
        </p>
557
        <Graph.graph_surface view_box="0 0 640 78" label="Work item statuses" prefix="demo-item">
558
          <Graph.graph_node
559
            :for={{status, i} <- Enum.with_index(Graph.item_statuses())}
560
            id={"demo-item-#{status}"}
561
            shape={:rect}
562
            x={44.0 + i * 90}
563
            y={30.0}
564
            width={44.0}
565
            height={30.0}
566
            status={status}
567
            label={to_string(status)}
568
          />
569
        </Graph.graph_surface>
570
      </div>
571
572
      <div class="space-y-2">
573
        <p class="text-sm text-base-content/60">Selected, and radius scaled by spend.</p>
574
        <Graph.graph_surface view_box="0 0 300 78" label="Node emphasis" prefix="demo-emph">
575
          <Graph.graph_node id="demo-sel" x={40.0} y={34.0} r={18.0} status={:running} selected />
576
          <Graph.graph_node id="demo-small" x={130.0} y={34.0} r={11.0} status={:running} />
577
          <Graph.graph_node id="demo-big" x={230.0} y={34.0} r={26.0} status={:running} />
578
        </Graph.graph_surface>
579
      </div>
580
    </div>
581
    """
582
  end
583
584
  defp component_demo(%{item: %{slug: "graph-link"}} = assigns) do
585
    ~H"""
586
    <div class="space-y-6">
587
      <div class="space-y-2">
588
        <p class="text-sm text-base-content/60">
589
          Every link kind. Distance is typed, so related things sit closer — the spacing
590
          here is each kind's own <code>link_distance/1</code>.
591
        </p>
592
        <Graph.graph_surface view_box="0 0 520 92" label="Link kinds" prefix="demo-kinds">
593
          <g :for={{kind, i} <- Enum.with_index(Graph.link_kinds())}>
594
            <Graph.graph_link
595
              id={"demo-link-#{kind}"}
596
              prefix="demo-kinds"
597
              source={%{shape: :circle, x: 34.0 + i * 104, y: 34.0, r: 13.0}}
598
              target={
599
                %{
600
                  shape: :rect,
601
                  x: 34.0 + i * 104 + 13.0 + Graph.link_distance(kind) + 11.0,
602
                  y: 34.0,
603
                  width: 22.0,
604
                  height: 16.0
605
                }
606
              }
607
              kind={kind}
608
            />
609
            <Graph.graph_node
610
              id={"demo-link-src-#{kind}"}
611
              x={34.0 + i * 104}
612
              y={34.0}
613
              r={13.0}
614
              status={:idle}
615
              label={to_string(kind)}
616
            />
617
            <Graph.graph_node
618
              id={"demo-link-tgt-#{kind}"}
619
              shape={:rect}
620
              x={34.0 + i * 104 + 13.0 + Graph.link_distance(kind) + 11.0}
621
              y={34.0}
622
              width={22.0}
623
              height={16.0}
624
              status={:admitted}
625
            />
626
          </g>
627
        </Graph.graph_surface>
628
      </div>
629
630
      <div class="space-y-2">
631
        <p class="text-sm text-base-content/60">
632
          A step traversing the link, and a labelled link. Terminations conform to the
633
          surface they touch: an arc into a circle, a flat bar into a rect.
634
        </p>
635
        <Graph.graph_surface
636
          view_box="0 0 320 92"
637
          label="Active and labelled links"
638
          prefix="demo-active"
639
        >
640
          <Graph.graph_link
641
            id="demo-link-active"
642
            prefix="demo-active"
643
            source={%{shape: :circle, x: 34.0, y: 34.0, r: 14.0}}
644
            target={%{shape: :rect, x: 150.0, y: 34.0, width: 24.0, height: 18.0}}
645
            kind={:normal}
646
            active
647
            label="step"
648
          />
649
          <Graph.graph_node id="demo-active-src" x={34.0} y={34.0} r={14.0} status={:running} />
650
          <Graph.graph_node
651
            id="demo-active-tgt"
652
            shape={:rect}
653
            x={150.0}
654
            y={34.0}
655
            width={24.0}
656
            height={18.0}
657
            status={:running}
658
          />
659
          <Graph.graph_link
660
            id="demo-link-back"
661
            prefix="demo-active"
662
            source={%{shape: :rect, x: 240.0, y: 34.0, width: 24.0, height: 18.0}}
663
            target={%{shape: :circle, x: 300.0, y: 34.0, r: 14.0}}
664
            kind={:normal}
665
          />
666
          <Graph.graph_node
667
            id="demo-back-src"
668
            shape={:rect}
669
            x={240.0}
670
            y={34.0}
671
            width={24.0}
672
            height={18.0}
673
            status={:completed}
674
          />
675
          <Graph.graph_node id="demo-back-tgt" x={300.0} y={34.0} r={14.0} status={:idle} />
676
        </Graph.graph_surface>
677
      </div>
678
    </div>
679
    """
680
  end
681
682
  defp component_demo(%{item: %{slug: "scv-swarm"}} = assigns) do
683
    ~H"""
684
    <div class="space-y-3">
685
      <p class="text-sm text-base-content/60">
686
        Fifteen SCVs at once. Position is derived from index, never from a mutable
687
        field, so "the third one down" keeps meaning the same agent.
688
      </p>
689
      <Graph.scv_swarm scvs={@demo_swarm} selected_id="scv-04" />
690
    </div>
691
    """
692
  end
504 693
end
test/openagents_web/components/graph_test.exs added +115

@@ -0,0 +1,115 @@

1
defmodule OpenAgentsWeb.UI.GraphTest do
2
  @moduledoc """
3
  The geometry is the part that can be silently wrong: a link terminating a few
4
  units inside a node still *looks* plausible. These assert the boundary
5
  condition directly, at many angles, rather than eyeballing one rendering.
6
  """
7
8
  use ExUnit.Case, async: true
9
10
  alias OpenAgentsWeb.UI.Graph
11
12
  describe "unit_vector/2" do
13
    test "is a unit vector for arbitrary separations" do
14
      for {a, b} <- [
15
            {{0.0, 0.0}, {3.0, 4.0}},
16
            {{-5.0, 2.0}, {7.0, -9.0}},
17
            {{1.0, 1.0}, {1.0, 9.0}}
18
          ] do
19
        {ux, uy} = Graph.unit_vector(a, b)
20
        assert_in_delta :math.sqrt(ux * ux + uy * uy), 1.0, 1.0e-9
21
      end
22
    end
23
24
    test "coincident points yield no direction rather than dividing by zero" do
25
      assert Graph.unit_vector({4.0, 4.0}, {4.0, 4.0}) == {0.0, 0.0}
26
    end
27
  end
28
29
  describe "surface_point/3" do
30
    test "a circle's surface point is exactly one radius from its centre" do
31
      node = %{shape: :circle, x: 10.0, y: 20.0, r: 7.0}
32
33
      for degrees <- 0..359//7 do
34
        rad = degrees * :math.pi() / 180.0
35
        u = {:math.cos(rad), :math.sin(rad)}
36
        {x, y} = Graph.surface_point(node, u)
37
38
        dx = x - node.x
39
        dy = y - node.y
40
        assert_in_delta :math.sqrt(dx * dx + dy * dy), node.r, 1.0e-9
41
      end
42
    end
43
44
    test "padding pushes the point outward by exactly that much" do
45
      node = %{shape: :circle, x: 0.0, y: 0.0, r: 5.0}
46
      {x, _} = Graph.surface_point(node, {1.0, 0.0}, 3)
47
      assert_in_delta x, 8.0, 1.0e-9
48
    end
49
50
    test "a rect's surface point lands on the boundary, never inside it" do
51
      node = %{shape: :rect, x: 0.0, y: 0.0, width: 20.0, height: 10.0}
52
53
      for degrees <- 0..359//5 do
54
        rad = degrees * :math.pi() / 180.0
55
        {x, y} = Graph.surface_point(node, {:math.cos(rad), :math.sin(rad)})
56
57
        on_x = abs(abs(x) - 10.0) < 1.0e-9 and abs(y) <= 5.0 + 1.0e-9
58
        on_y = abs(abs(y) - 5.0) < 1.0e-9 and abs(x) <= 10.0 + 1.0e-9
59
60
        assert on_x or on_y, "#{degrees} deg gave {#{x}, #{y}}, not on the boundary"
61
      end
62
    end
63
64
    test "an axis-aligned vector does not divide by a zero component" do
65
      node = %{shape: :rect, x: 0.0, y: 0.0, width: 8.0, height: 6.0}
66
      assert {+0.0, 3.0} = Graph.surface_point(node, {0.0, 1.0})
67
      assert {4.0, +0.0} = Graph.surface_point(node, {1.0, 0.0})
68
    end
69
  end
70
71
  describe "link_distance/1" do
72
    test "proximity encodes kind, matching Unit's ratios" do
73
      base = Graph.link_distance()
74
75
      assert Graph.link_distance(:type) == base / 2
76
      assert Graph.link_distance(:data) == base / 2
77
      assert Graph.link_distance(:error) == base * 7 / 8
78
      assert Graph.link_distance(:exposed) == base * 2 / 3
79
      assert Graph.link_distance(:normal) == base
80
81
      assert Graph.link_distance(:error) < Graph.link_distance(:normal)
82
    end
83
  end
84
85
  describe "the state taxonomy" do
86
    test "covers the SCV lifecycle from docs/scv-planning.md" do
87
      assert Graph.statuses() == [:idle, :running, :paused, :circuit_open, :disabled]
88
    end
89
90
    test "covers the work-item lifecycle from docs/scv-planning.md" do
91
      assert Graph.item_statuses() ==
92
               [:discovered, :admitted, :running, :completed, :deferred, :refused, :failed]
93
    end
94
95
    test "every rendered status has a style rule in the style pack" do
96
      css = File.read!("assets/css/openagents.css")
97
98
      for status <- Graph.statuses() ++ Graph.item_statuses() do
99
        assert css =~ ~s([data-status="#{status}"]),
100
               "#{status} renders but has no style rule, so it would be indistinguishable"
101
      end
102
103
      for kind <- Graph.link_kinds() -- [:normal] do
104
        assert css =~ ~s([data-kind="#{kind}"]), "link kind #{kind} has no style rule"
105
      end
106
    end
107
  end
108
109
  describe "describe_arc/5" do
110
    test "produces a valid arc command" do
111
      assert Graph.describe_arc(6.0, 6.0, 5.0, 210, 330) =~
112
               ~r/^M [\d.-]+ [\d.-]+ A 5\.0 5\.0 0 0 0/
113
    end
114
  end
115
end
test/openagents_web/icon_affordances_test.exs modified +14 -3

@@ -66,13 +66,24 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

66 66
    test "no surface hand-writes an svg outside the vendored set" do
67 67
      # Every glyph must come from `priv/icons` through `icon/1`. A pasted
68 68
      # `<svg>` in a template is how a second, unmanaged icon set starts.
69
      # The exempt files implement the vendored set. `icons.ex` holds embedded
70
      # markup and `ui.ex` renders the one governed root element.
69
      #
70
      # `icons.ex` holds the embedded markup and `ui.ex` renders the one
71
      # governed root element, so both implement the vendored set rather than
72
      # bypassing it.
73
      #
74
      # `graph.ex` is exempt for a different reason: it draws data, not
75
      # affordances. Its circles, rects and paths are positioned from live SCV
76
      # geometry and cannot come from a fixed glyph set — there is no icon for
77
      # "a link terminating on this node's surface at this angle". The rule this
78
      # test defends is "one icon set", not "no vector output", and a graph node
79
      # is not an icon. Any glyph *inside* a graph surface still goes through
80
      # `icon/1`.
81
      exempt = ["ui.ex", "icons.ex", "graph.ex"]
71 82
72 83
      offenders =
73 84
        "lib/openagents_web/**/*.{ex,heex}"
74 85
        |> Path.wildcard()
75
        |> Enum.reject(&String.ends_with?(&1, ["ui.ex", "icons.ex"]))
86
        |> Enum.reject(&String.ends_with?(&1, exempt))
76 87
        |> Enum.filter(fn path -> path |> File.read!() |> String.contains?("<svg") end)
77 88
78 89
      assert offenders == [],

This page updates live while a promote is in flight · changelog