leyline-code 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,1622 @@
1
+ <title>Leyline map of __LEYLINE_REPO__</title>
2
+ <style>
3
+ /* Layout: a survey sheet. Map canvas left, inspector right; one header row of navigation above.
4
+ No web fonts: the page fetches nothing, so it uses the faces already on the machine. */
5
+ :root {
6
+ --bg: #ecf0ed; --surface: #f8faf8; --sunken: #e2e8e4; --ink: #15201c; --muted: #586860;
7
+ --line: #c6d0ca; --grid: #e2e8e4; --edge: #7c8c84; --accent: #0b6a5b; --accent-soft: #d3e9e3; --mark: #b0561a;
8
+ --mark-soft: #f4dfcd; --cs: #5b4fb3; --py: #8f6410; --warn: #a3341f; --warn-soft: #f3d9d2; --chan: #1f62ad;
9
+ --cross: #a2387f; --ok: #2f7a3d; --ok-soft: #d8ecd9;
10
+ --sans: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
11
+ --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
12
+ }
13
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
14
+ --bg: #0f1513; --surface: #161e1b; --sunken: #0b100e; --ink: #e2e9e5; --muted: #8d9d95;
15
+ --line: #2a3631; --grid: #141c19; --edge: #6e7f77; --accent: #55c4ab; --accent-soft: #173a33; --mark: #e8a15c;
16
+ --mark-soft: #40291a; --cs: #a99df2; --py: #ddb155; --warn: #f08a75; --warn-soft: #43201a; --chan: #7db5f2;
17
+ --cross: #e48cc8; --ok: #7fcf8c; --ok-soft: #1b3520;
18
+ color-scheme: dark } }
19
+ :root[data-theme="dark"] {
20
+ --bg: #0f1513; --surface: #161e1b; --sunken: #0b100e; --ink: #e2e9e5; --muted: #8d9d95;
21
+ --line: #2a3631; --grid: #141c19; --edge: #6e7f77; --accent: #55c4ab; --accent-soft: #173a33; --mark: #e8a15c;
22
+ --mark-soft: #40291a; --cs: #a99df2; --py: #ddb155; --warn: #f08a75; --warn-soft: #43201a; --chan: #7db5f2;
23
+ --cross: #e48cc8; --ok: #7fcf8c; --ok-soft: #1b3520;
24
+ color-scheme: dark }
25
+
26
+ html, body { height: 100%; }
27
+ body { background: var(--bg); color: var(--ink); font: 14px/1.45 var(--sans); }
28
+ #app { height: 100%; display: flex; flex-direction: column; min-height: 0; }
29
+ #app *, #app *::before { box-sizing: border-box; }
30
+ button, input { font: inherit; color: inherit; }
31
+ button { cursor: pointer; }
32
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
33
+
34
+ .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding-block: 10px;
35
+ padding-inline: 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
36
+ .brand { font-weight: 600; letter-spacing: 0.02em; display: flex; align-items: baseline; gap: 8px; }
37
+ .brand small { font: 12px var(--mono); color: var(--muted); font-weight: 400; }
38
+ .crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; flex: 1 1 240px; }
39
+ .crumbs button { background: none; border: 0; padding: 3px 6px; border-radius: 4px; font: 13px var(--mono);
40
+ color: var(--muted); max-width: 100%; overflow-wrap: anywhere; text-align: left; }
41
+ .crumbs button:hover { background: var(--sunken); color: var(--ink); }
42
+ .crumbs button[aria-current] { color: var(--ink); font-weight: 500; }
43
+ .crumbs .sep { color: var(--line); }
44
+ .tabs { display: flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
45
+ .tabs button { background: none; border: 0; padding: 5px 12px; color: var(--muted); }
46
+ .tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 500; }
47
+ .tabs .count { font: 11px var(--mono); color: var(--muted); margin-left: 4px; }
48
+ .morewrap { position: relative; display: flex; }
49
+ .menu { position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; min-width: 210px; background: var(--surface);
50
+ border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 6px 18px rgb(0 0 0 / 0.18); padding: 4px 0; }
51
+ .menu button { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 7px 12px; }
52
+ .menu button small { display: block; color: var(--muted); font-size: 12px; }
53
+ .menu button:hover, .menu button:focus-visible { background: var(--accent-soft); }
54
+ .menu button[aria-pressed="true"] { box-shadow: inset 3px 0 0 var(--accent); }
55
+ .search { position: relative; flex: 0 1 240px; min-width: 150px; }
56
+ .search input { width: 100%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px;
57
+ background: var(--bg); }
58
+ .results { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; right: 0; min-width: 260px;
59
+ background: var(--surface); border: 1px solid var(--line); border-radius: 6px; max-height: 320px;
60
+ overflow: auto; box-shadow: 0 6px 18px rgb(0 0 0 / 0.18); }
61
+ .results button { display: block; width: 100%; text-align: left; background: none; border: 0;
62
+ padding: 6px 10px; border-bottom: 1px solid var(--line); }
63
+ .results button:last-child { border-bottom: 0; }
64
+ .results button:hover, .results button:focus-visible { background: var(--accent-soft); }
65
+ .results b { font: 500 13px var(--mono); display: block; overflow-wrap: anywhere; }
66
+ .results span { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
67
+
68
+ .main { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); }
69
+ .stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background:
70
+ linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 48px,
71
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 48px 100%, var(--bg); }
72
+ #map { width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; user-select: none; }
73
+ #map.dragging { cursor: grabbing; }
74
+ .tools { position: absolute; z-index: 3; left: 12px; top: 12px; display: flex; flex-wrap: wrap; gap: 6px;
75
+ max-width: calc(100% - 24px); }
76
+ .chip { border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 3px 10px;
77
+ font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; }
78
+ .chip[aria-pressed="true"] { color: var(--ink); border-color: var(--edge); }
79
+ .chip[aria-pressed="true"]::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
80
+ .chip.zoom { border-radius: 6px; padding: 3px 9px; color: var(--ink); }
81
+ .hint { position: absolute; left: 12px; bottom: 10px; right: 12px; font-size: 12px; color: var(--muted);
82
+ display: flex; flex-wrap: wrap; gap: 4px 16px; pointer-events: none; }
83
+ .hint i { font-style: normal; display: inline-flex; align-items: center; gap: 6px; }
84
+ .hint svg { width: 30px; height: 8px; }
85
+
86
+ .node rect { fill: var(--surface); stroke: var(--edge); stroke-width: 1.2; }
87
+ .node text { fill: var(--ink); font: 500 12.5px var(--mono); pointer-events: none; }
88
+ .node text.meta { fill: var(--muted); font: 11px var(--sans); }
89
+ .node { cursor: pointer; }
90
+ .node.stub rect { fill: var(--bg); stroke-dasharray: 2 3; }
91
+ .node.stub text { fill: var(--muted); }
92
+ .node.iso rect { stroke: var(--line); }
93
+ .node:hover rect { stroke: var(--accent); }
94
+ .node.sel rect { stroke: var(--mark); stroke-width: 2.2; fill: var(--mark-soft); }
95
+ .node.near rect { stroke: var(--accent); stroke-width: 1.8; }
96
+ .node .lang-csharp { fill: var(--cs); } .node .lang-python { fill: var(--py); } .node .lang-none { fill: var(--edge); }
97
+ .node .flag { fill: var(--mark); }
98
+ .dim { opacity: 0.18; }
99
+ .dense .edge:not(.near):not(.sel) path.vis { opacity: 0.45; }
100
+ .edge path.vis { fill: none; stroke: var(--edge); }
101
+ .edge.heur path.vis { stroke-dasharray: 5 4; }
102
+ .edge.guess path.vis { stroke-dasharray: 1.5 4; stroke-linecap: round; }
103
+ .edge.comm path.vis { stroke: var(--chan); stroke-dasharray: 9 3 2 3; }
104
+ .edge.comm text { fill: var(--chan); }
105
+ #arrow-c path { fill: var(--chan); }
106
+ .edge path.hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
107
+ .edge text { font: 11px var(--mono); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; pointer-events: none; }
108
+ .edge:hover path.vis, .edge.near path.vis { stroke: var(--accent); }
109
+ .edge.near text { fill: var(--ink); }
110
+ .edge.sel path.vis { stroke: var(--mark); }
111
+ .edge.sel text { fill: var(--mark); font-weight: 500; }
112
+ #arrow path { fill: var(--edge); } #arrow-a path { fill: var(--accent); } #arrow-m path { fill: var(--mark); } #arrow-x path { fill: var(--cross); }
113
+ .edge.cross path.vis { stroke: var(--cross); }
114
+ .edge.cross text { fill: var(--cross); }
115
+ .node.other rect:first-child { stroke: var(--cross); stroke-dasharray: 2 3; }
116
+ .group-label { font: 11px var(--sans); fill: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; }
117
+
118
+ .matrix-wrap { height: 100%; overflow: auto; padding: 56px 16px 40px; }
119
+ table.matrix { border-collapse: collapse; font: 11.5px var(--mono); font-variant-numeric: tabular-nums; }
120
+ .matrix th { font-weight: 400; color: var(--muted); white-space: nowrap; }
121
+ .matrix th.row { text-align: right; padding: 0 8px 0 0; position: sticky; left: 0; background: var(--bg); z-index: 1;
122
+ max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
123
+ .matrix th.col { height: 160px; vertical-align: bottom; padding: 0; }
124
+ .matrix th.col div { writing-mode: vertical-rl; transform: rotate(180deg); padding: 6px 0; max-height: 158px;
125
+ overflow: hidden; text-overflow: ellipsis; margin: 0 auto; }
126
+ .matrix td { width: 26px; min-width: 26px; height: 24px; text-align: center; border: 1px solid var(--line);
127
+ background: var(--surface); padding: 0; }
128
+ .matrix td.diag { background: var(--sunken); }
129
+ .matrix td.on { cursor: pointer; color: var(--ink); }
130
+ .matrix td.back { background: var(--warn-soft); color: var(--warn); font-weight: 500; }
131
+ .matrix td.sel { outline: 2px solid var(--mark); outline-offset: -2px; }
132
+ .matrix tr:hover th.row { color: var(--ink); }
133
+ .matrix-note { font-size: 12px; color: var(--muted); max-width: 62ch; margin: 0 0 14px; }
134
+
135
+ .side { border-left: 1px solid var(--line); background: var(--surface); overflow: auto; min-width: 0; padding: 16px; }
136
+ .side h2 { font: 600 16px/1.25 var(--mono); margin: 0; overflow-wrap: anywhere; text-wrap: balance; }
137
+ .side h3 { font: 600 11px var(--sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted);
138
+ margin: 18px 0 6px; display: flex; justify-content: space-between; gap: 8px; }
139
+ .side h3 span { font-weight: 400; letter-spacing: 0; text-transform: none; }
140
+ .eyebrow { font: 11px var(--sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-bottom: 3px;
141
+ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
142
+ .tag { font: 11px var(--sans); letter-spacing: 0; text-transform: none; border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; color: var(--muted); }
143
+ .tag.entry { border-color: var(--mark); color: var(--mark); }
144
+ .tag.heur { border-style: dashed; }
145
+ .sig { font: 12.5px/1.5 var(--mono); background: var(--sunken); padding: 8px 10px; border-radius: 6px; margin-top: 10px;
146
+ overflow-wrap: anywhere; }
147
+ .where { font: 12px var(--mono); color: var(--muted); margin-top: 6px; overflow-wrap: anywhere; }
148
+ .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(58px, 1fr)); gap: 8px; margin-top: 12px; }
149
+ .facts div { border-top: 2px solid var(--line); padding-top: 4px; }
150
+ .facts b { font: 500 17px var(--mono); display: block; font-variant-numeric: tabular-nums; }
151
+ .facts span { font-size: 11.5px; color: var(--muted); }
152
+ .list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
153
+ .list li { border-top: 1px solid var(--line); }
154
+ .list button { display: flex; width: 100%; gap: 8px; justify-content: space-between; align-items: baseline;
155
+ background: none; border: 0; padding: 5px 2px; text-align: left; }
156
+ .list button:hover { background: var(--accent-soft); }
157
+ .list .nm { font: 12.5px var(--mono); overflow-wrap: anywhere; min-width: 0; }
158
+ .list .nm small { display: block; font: 11.5px var(--sans); color: var(--muted); }
159
+ .list .ct { font: 12px var(--mono); color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
160
+ .list .plain { padding: 5px 2px; display: flex; justify-content: space-between; gap: 8px; }
161
+ .more { font-size: 12px; color: var(--muted); padding: 5px 2px; border-top: 1px solid var(--line); }
162
+ .open { margin-top: 12px; background: var(--accent); color: var(--surface); border: 0; border-radius: 6px; padding: 7px 14px; font-weight: 500; }
163
+ .open:hover { filter: brightness(1.1); }
164
+ .note { font-size: 12.5px; color: var(--muted); margin: 8px 0 0; max-width: 60ch; }
165
+ pre.code { font: 12px/1.5 var(--mono); background: var(--sunken); border-radius: 6px; padding: 8px 0; margin: 0;
166
+ overflow: auto; max-height: 420px; tab-size: 4; }
167
+ pre.code code { display: block; padding: 0 10px 0 0; white-space: pre; }
168
+ pre.code.diagram { margin: 8px 0; } pre.code.diagram code { padding-left: 10px; }
169
+ pre.code .ln { display: inline-block; width: 3.6em; padding-right: 0.9em; text-align: right; color: var(--muted);
170
+ user-select: none; opacity: 0.7; }
171
+ .status { font: 12px var(--mono); }
172
+ .status.ok { color: var(--accent); } .status.na { color: var(--muted); }
173
+ .tag.chan { border-color: var(--chan); color: var(--chan); }
174
+ .tag.risk-high { border-color: var(--warn); color: var(--warn); } .tag.risk-medium { border-color: var(--mark); color: var(--mark); }
175
+ .node.role-changed rect:first-child { fill: var(--mark-soft); stroke: var(--mark); stroke-width: 2.4; }
176
+ .node.role-new rect:first-child { fill: var(--mark-soft); stroke: var(--mark); stroke-width: 2; stroke-dasharray: 5 3; }
177
+ .node.role-contract rect:first-child { fill: var(--warn-soft); stroke: var(--warn); stroke-width: 2; stroke-dasharray: 6 3; }
178
+ .node.role-must_edit rect:first-child { fill: var(--warn-soft); stroke: var(--warn); stroke-width: 1.8; }
179
+ .node.role-direct rect:first-child { stroke: var(--accent); stroke-width: 1.8; fill: var(--accent-soft); }
180
+ .node.role-test rect:first-child { stroke: var(--chan); stroke-width: 1.6; }
181
+ .node.role-indirect rect:first-child { stroke: var(--line); }
182
+ .node.role-c0 rect:first-child { stroke: var(--accent); stroke-width: 2; fill: var(--accent-soft); }
183
+ .node.role-c1 rect:first-child { stroke: var(--mark); stroke-width: 2; fill: var(--mark-soft); }
184
+ .node.role-c2 rect:first-child { stroke: var(--chan); stroke-width: 2; }
185
+ .node.role-c3 rect:first-child { stroke: var(--warn); stroke-width: 2; fill: var(--warn-soft); }
186
+ .node.role-c4 rect:first-child { stroke: var(--cs); stroke-width: 2; }
187
+ .node.role-c5 rect:first-child { stroke: var(--py); stroke-width: 2; }
188
+ .node.role-done rect:first-child { fill: var(--ok-soft); stroke: var(--ok); stroke-width: 2.4; }
189
+ .node.role-done_new rect:first-child { fill: var(--ok-soft); stroke: var(--ok); stroke-width: 2.2; stroke-dasharray: 5 3; }
190
+ .node.role-helper rect:first-child { fill: var(--ok-soft); stroke: var(--ok); stroke-width: 1.4; stroke-dasharray: 2 3; }
191
+ .node.role-test_changed rect:first-child { stroke: var(--chan); stroke-width: 2; }
192
+ .node.role-drift rect:first-child { fill: var(--warn-soft); stroke: var(--warn); stroke-width: 2.4; }
193
+ .node.role-missed rect:first-child { fill: var(--mark-soft); stroke: var(--mark); stroke-width: 2; stroke-dasharray: 6 3; }
194
+ .node.picked rect:first-child { stroke-width: 3.4; }
195
+ .node .pin { fill: var(--ink); font: 600 11px var(--sans); }
196
+ .key { display: inline-flex; align-items: center; gap: 6px; }
197
+ .key b { width: 14px; height: 10px; border-radius: 2px; border: 2px solid var(--line); display: inline-block; }
198
+ .key .k-changed, .key .k-c1, .key .k-new { background: var(--mark-soft); border-color: var(--mark); }
199
+ .key .k-must_edit, .key .k-contract, .key .k-c3 { background: var(--warn-soft); border-color: var(--warn); }
200
+ .key .k-direct, .key .k-c0 { background: var(--accent-soft); border-color: var(--accent); }
201
+ .key .k-test, .key .k-c2, .key .k-test_changed { border-color: var(--chan); } .key .k-c4 { border-color: var(--cs); } .key .k-c5 { border-color: var(--py); }
202
+ .key .k-done, .key .k-done_new, .key .k-helper { background: var(--ok-soft); border-color: var(--ok); }
203
+ .key .k-drift { background: var(--warn-soft); border-color: var(--warn); } .key .k-missed { background: var(--mark-soft); border-color: var(--mark); border-style: dashed; }
204
+ .key .k-direct, .key .k-indirect { border-color: var(--accent); } .key .k-indirect { border-color: var(--line); }
205
+ .tools select { font: 13px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--edge);
206
+ border-radius: 6px; padding: 4px 8px; max-width: min(100%, 460px); }
207
+ .flow-wrap { height: 100%; overflow: auto; padding: 14px 16px 40px; display: flex; flex-direction: column; gap: 12px; }
208
+ .flow-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
209
+ .flow-head select { font: 13px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--line);
210
+ border-radius: 6px; padding: 6px 8px; max-width: 100%; min-width: 0; flex: 1 1 280px; }
211
+ .flow-head span { font-size: 12px; color: var(--muted); }
212
+ .steps button.idle { opacity: .55; }
213
+ .tour { border: 1px solid var(--accent); background: var(--accent-soft); border-radius: 8px; padding: 12px 14px 12px; margin-bottom: 18px; }
214
+ .tour h2 { margin-top: 4px; }
215
+ .tour .note { color: var(--ink); }
216
+ .tour-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12.5px; }
217
+ .tour-top select { font: 12.5px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; min-width: 0; max-width: 100%; }
218
+ .tour-nav { display: flex; gap: 8px; margin-top: 10px; }
219
+ .tour button { font: 12.5px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 5px 11px; cursor: pointer; }
220
+ .tour button.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); margin-left: auto; }
221
+ .tour button:disabled { opacity: .45; cursor: default; }
222
+ .flow-head input { font: 13px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; min-width: 190px; }
223
+ .flow-head select { max-width: min(100%, 560px); }
224
+ .steps { list-style: none; margin: 0; padding: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 6px;
225
+ overflow-x: auto; }
226
+ .steps li { border-top: 1px solid var(--line); min-width: max-content; }
227
+ .steps li:first-child { border-top: 0; }
228
+ .steps button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: 0; text-align: left;
229
+ padding: 4px 10px; font: 12.5px var(--mono); white-space: nowrap; }
230
+ .steps button:hover { background: var(--accent-soft); }
231
+ .steps button[aria-current="true"] { background: var(--mark-soft); box-shadow: inset 3px 0 0 var(--mark); }
232
+ .steps .dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }
233
+ .steps .seq { color: var(--muted); font-size: 11px; width: 2.4em; text-align: right; flex: none; font-variant-numeric: tabular-nums; }
234
+ .steps .ind { flex: none; width: calc(var(--d) * 18px); }
235
+ .steps .mod { color: var(--muted); font: 11.5px var(--sans); margin-left: auto; padding-left: 24px; }
236
+ .steps .via { font: 11px var(--sans); color: var(--chan); border: 1px solid var(--chan); border-radius: 4px; padding: 0 5px; }
237
+
238
+ .state { border-radius: 6px; padding: 8px 10px; margin: 12px 0 0; border: 1px solid var(--line); background: var(--sunken); }
239
+ .state.ok { border-color: var(--ok); background: var(--ok-soft); } .state.todo { border-color: var(--mark); background: var(--mark-soft); }
240
+ .state.ready { border-color: var(--accent); background: var(--accent-soft); }
241
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; background: var(--surface); }
242
+ .seg button { background: none; border: 0; padding: 4px 11px; font-size: 13px; color: var(--muted); }
243
+ .seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 500; }
244
+ .onmap { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }
245
+ .keylist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
246
+ .keylist li { display: flex; align-items: center; gap: 8px; }
247
+ .keylist .key b { flex: none; }
248
+ .keylist .n { font: 12px var(--mono); color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
249
+ .md { font-size: 13.5px; }
250
+ .md h3 { margin-top: 22px; }
251
+ .md p { margin: 8px 0 0; max-width: 64ch; }
252
+ .md ul { margin: 6px 0 0; padding-left: 18px; }
253
+ .md li { margin: 2px 0; }
254
+ .md code { font: 12px var(--mono); background: var(--sunken); border-radius: 3px; padding: 0 3px; overflow-wrap: anywhere; }
255
+ .md .tbl { overflow-x: auto; margin-top: 8px; }
256
+ .md table { border-collapse: collapse; font-size: 12.5px; width: 100%; }
257
+ .md th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11.5px; border-bottom: 1px solid var(--line); padding: 4px 6px 4px 0; }
258
+ .md td { border-top: 1px solid var(--line); padding: 5px 6px 5px 0; vertical-align: top; overflow-wrap: anywhere; }
259
+ .md tr[data-task], .md tr[data-scenario] { cursor: pointer; }
260
+ .md tr[data-task]:hover td, .md tr[data-scenario]:hover td { background: var(--accent-soft); }
261
+ .md tr[aria-current="true"] td { background: var(--mark-soft); }
262
+ .md td.res-done, .md td.res-passes { color: var(--ok); font-weight: 500; }
263
+ .md td.res-not-done, .md td.res-fails, .md td.res-partly, .md td.res-no-test { color: var(--warn); font-weight: 500; }
264
+ .changes { list-style: none; margin: 8px 0 0; padding: 0; }
265
+ .changes button { display: block; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--line);
266
+ border-radius: 6px; padding: 9px 11px; margin-top: 8px; }
267
+ .changes button:hover { border-color: var(--accent); }
268
+ .changes b { display: block; font-weight: 600; }
269
+ .changes small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
270
+ .banner { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: flex-end; border: 1px solid var(--accent);
271
+ background: var(--accent-soft); border-radius: 6px; padding: 6px 10px; margin-bottom: 14px; font-size: 12.5px; }
272
+ .banner span { flex: 1 1 100%; }
273
+ .banner button { font: 12.5px var(--sans); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 3px 9px; flex: none; }
274
+ .loading { padding: 24px 16px; color: var(--muted); }
275
+
276
+ @media (max-width: 820px) {
277
+ html, body { height: auto; }
278
+ #app { height: auto; }
279
+ .main { display: flex; flex-direction: column; }
280
+ .stage { height: 62vh; min-height: 340px; }
281
+ .side { border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
282
+ .search { flex: 1 1 100%; }
283
+ .crumbs { flex-basis: 100%; order: 3; }
284
+ }
285
+ @media (prefers-reduced-motion: no-preference) { .node rect, .edge path.vis { transition: stroke 0.12s, opacity 0.12s; } }
286
+ </style>
287
+
288
+ <div id="app">
289
+ <header class="bar">
290
+ <div class="brand">Leyline <small id="commit"></small></div>
291
+ <nav class="crumbs" id="crumbs" aria-label="Location"></nav>
292
+ <div class="tabs" role="group" aria-label="View">
293
+ <button id="tab-map" aria-pressed="true">Map</button>
294
+ <button id="tab-change" aria-pressed="false">Changes<span class="count" id="change-count"></span></button>
295
+ <div class="morewrap">
296
+ <button id="tab-more" aria-pressed="false" aria-haspopup="true" aria-expanded="false">More</button>
297
+ <div class="menu" id="menu" hidden>
298
+ <button id="tab-flows" aria-pressed="false">Flows<small>what a test or an entry point runs through</small></button>
299
+ <button id="tab-matrix" aria-pressed="false">Matrix<small>dependencies as a grid, cycles tinted</small></button>
300
+ <button id="tab-views" aria-pressed="false">Saved views<small>marked sets of code, design patterns</small></button>
301
+ <button id="tab-tour" aria-pressed="false">Tour<small>a guided walk through the code</small></button>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ <div class="search">
306
+ <input id="q" type="search" placeholder="Find a type, function or file" autocomplete="off" aria-label="Search">
307
+ <div class="results" id="results" hidden></div>
308
+ </div>
309
+ </header>
310
+ <div class="main">
311
+ <section class="stage" aria-label="Map">
312
+ <svg id="map" role="img" aria-label="Dependency map">
313
+ <defs>
314
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z"/></marker>
315
+ <marker id="arrow-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z"/></marker>
316
+ <marker id="arrow-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z"/></marker>
317
+ <marker id="arrow-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z"/></marker>
318
+ <marker id="arrow-x" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z"/></marker>
319
+ </defs>
320
+ <g id="world"><g id="edges"></g><g id="nodes"></g></g>
321
+ </svg>
322
+ <div class="matrix-wrap" id="matrix" hidden></div>
323
+ <div class="flow-wrap" id="flowview" hidden></div>
324
+ <div class="tools" id="tools"></div>
325
+ <div class="hint" id="viewkey" hidden></div>
326
+ <div class="hint" id="hint">
327
+ <i><svg viewBox="0 0 30 8"><line x1="0" y1="4" x2="30" y2="4" stroke="currentColor" stroke-width="1.5"/></svg>exact</i>
328
+ <i><svg viewBox="0 0 30 8"><line x1="0" y1="4" x2="30" y2="4" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4"/></svg>worked out from syntax</i>
329
+ <i><svg viewBox="0 0 30 8"><line x1="0" y1="4" x2="30" y2="4" stroke="currentColor" stroke-width="2" stroke-dasharray="1.5 4" stroke-linecap="round"/></svg>guessed by name</i>
330
+ <i><svg viewBox="0 0 30 8"><line x1="0" y1="4" x2="30" y2="4" stroke="var(--chan)" stroke-width="1.5" stroke-dasharray="9 3 2 3"/></svg>a channel that is not a call (event, launch, message, table…), in the direction data moves</i>
331
+ <i id="hint-cross" hidden><svg viewBox="0 0 30 8"><line x1="0" y1="4" x2="30" y2="4" stroke="var(--cross)" stroke-width="1.5"/></svg>into another repository</i>
332
+ <i>Other arrows point at what is depended on. Double-click a box to open it.</i>
333
+ </div>
334
+ </section>
335
+ <aside class="side" id="side" aria-live="polite"><p class="loading">Loading the map.</p></aside>
336
+ </div>
337
+ </div>
338
+
339
+ <script id="leyline-data" type="application/json">__LEYLINE_DATA__</script>
340
+ <script>
341
+ (async () => {
342
+ "use strict";
343
+ // A big map is embedded gzipped (export.py says when); the browser unpacks it, with no fetch.
344
+ async function load() {
345
+ const box = document.getElementById("leyline-data");
346
+ if (box.type === "application/json") return JSON.parse(box.textContent);
347
+ const bin = atob(box.textContent.trim()), bytes = new Uint8Array(bin.length);
348
+ for (let k = 0; k < bin.length; k++) bytes[k] = bin.charCodeAt(k);
349
+ const text = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"))).text();
350
+ return JSON.parse(text);
351
+ }
352
+ let D;
353
+ try { D = await load(); } catch (err) {
354
+ document.getElementById("side").innerHTML = `<p class="loading">This page could not read its own data (${String(err).replace(/[<&]/g, "")}). A browser from 2023 or later is needed for large maps.</p>`;
355
+ throw err;
356
+ }
357
+ const N = D.nodes;
358
+ const $ = (id) => document.getElementById(id);
359
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
360
+ const SVG = "http://www.w3.org/2000/svg";
361
+ const el = (tag, attrs = {}, parent) => {
362
+ const e = document.createElementNS(SVG, tag);
363
+ for (const k in attrs) e.setAttribute(k, attrs[k]);
364
+ if (parent) parent.appendChild(e);
365
+ return e;
366
+ };
367
+
368
+ /* ---------- index ---------- */
369
+ const kids = N.map(() => []);
370
+ N.forEach((n, i) => { n.idx = i; if (n.p != null) kids[n.p].push(i); });
371
+ const up = (i, test) => { for (let c = i; c != null; c = N[c].p) if (test(N[c])) return c; return null; };
372
+ const moduleOf = N.map((n, i) => up(i, (m) => m.k === "module"));
373
+ const fileOf = N.map((n, i) => up(i, (m) => m.k === "file"));
374
+ const repoOf = N.map((n, i) => up(i, (m) => m.k === "repo"));
375
+ // A workspace: several repositories in one store. Then the repositories are the top of the map.
376
+ const REPOS = N.map((n, i) => i).filter((i) => N[i].k === "repo");
377
+ const WS = REPOS.length > 1;
378
+ const repoMeta = (r) => D.repos.find((x) => x.id === N[r].i) || {};
379
+ const otherRepo = (i, home) => WS && home != null && repoOf[i] != null && repoOf[i] !== repoOf[home];
380
+ const srcKey = (i) => (WS ? N[repoOf[i]].i + "/" : "") + N[i].f;
381
+ // The unit a node belongs to: its outermost type, or its file when it sits loose in one.
382
+ const unitOf = N.map((n, i) => {
383
+ if (n.k === "module" || n.k === "repo" || n.k === "external" || n.k === "system") return null;
384
+ if (n.k === "file") return i;
385
+ let c = i;
386
+ while (N[c].p != null && N[N[c].p].k !== "file") c = N[c].p;
387
+ return N[c].k === "type" ? c : fileOf[i];
388
+ });
389
+ // The member of a unit a node belongs to: the child of the unit on the way down to it.
390
+ const memberOf = (i) => {
391
+ const u = unitOf[i];
392
+ if (u == null || u === i) return null;
393
+ let c = i;
394
+ while (N[c].p != null && N[c].p !== u && N[N[c].p].k !== "file") c = N[c].p;
395
+ return c;
396
+ };
397
+ const GROUPS = { calls: "calls", uses_type: "types", instantiates: "types", extends: "inherit",
398
+ implements: "inherit", imports: "imports", depends_on: "imports", communicates: "channels" };
399
+ const LABEL = { calls: "calls", uses_type: "uses type", instantiates: "creates", extends: "extends",
400
+ implements: "implements", imports: "imports", depends_on: "depends on", communicates: "channel",
401
+ event: "raises an event handled by", process: "launches" };
402
+ // Channels that are not calls: [one end to the other, the other end seen from the source, seen from the target, a flow step].
403
+ const CHAN = {
404
+ event: ["raises an event handled in", "its event is handled here", "raises an event handled here", "through an event"],
405
+ process: ["starts as a separate program", "launches it", "launched from here", "starts a program"],
406
+ http: ["sends a request served in", "serves its request", "sends a request served here", "over HTTP"],
407
+ file: ["writes a file read in", "reads a file it writes", "writes a file read here", "through a file"],
408
+ di: ["is handed, through a dependency-injection container, to", "reached through the container", "reaches this through the container", "through the container"],
409
+ queue: ["sends a message handled in", "handles its messages", "sends messages handled here", "through a message"],
410
+ db: ["writes a table read in", "reads a table it writes", "writes a table read here", "through a table"],
411
+ rpc: ["makes a remote call served in", "serves its remote call", "makes a remote call served here", "over RPC"],
412
+ format: ["builds keys taken apart in", "takes apart the keys it builds", "builds keys taken apart here", "through a key's shape"],
413
+ };
414
+ Object.assign(LABEL, { http: "sends a request to", file: "writes a file read by", di: "dependency injection",
415
+ queue: "sends a message to", db: "writes a table read by", rpc: "makes a remote call to",
416
+ format: "builds keys taken apart by" });
417
+ // One flat list of links: [kind, src, dst, rank (2 exact, 1 from syntax, 0 guessed), count, line, channel info]
418
+ const LINKS = [];
419
+ for (const [k, s, d, x, extra] of D.edges) if (GROUPS[k]) LINKS.push([k, s, d, x, 1, 0, k === "communicates" ? extra : null]);
420
+ for (const [s, d, n, line, x] of D.calls) LINKS.push(["calls", s, d, x, n, line, null]);
421
+ // Systems: groups of units inside a module. A note named "name" renames a proposed system.
422
+ const NOTES = D.notes || {};
423
+ const sysOf = new Map(), unitsOf = new Map();
424
+ for (const [k, s, d] of D.edges) if (k === "groups") { sysOf.set(d, s); (unitsOf.get(s) || unitsOf.set(s, []).get(s)).push(d); }
425
+ N.forEach((n, i) => { if (n.k === "system" && NOTES[i] && NOTES[i].name) { n.proposed = n.n; n.n = NOTES[i].name.v; } });
426
+ const grp = (u) => (sysOf.has(u) ? sysOf.get(u) : u);
427
+ // Parts: a large module drawn as `leyline outline` splits it, two levels deep. Each part is a node of kind "part"
428
+ // whose parent is its module or the part it was split from; a level's parts hold every file of what was split.
429
+ const PARTS = new Map(), topPart = new Map(), subPart = new Map(); // part -> its files; file -> its part at each level
430
+ for (const p of D.parts || []) { const files = new Set(p.files), top = N[N[p.i].p].k === "module"; PARTS.set(p.i, files); for (const f of files) (top ? topPart : subPart).set(f, p.i); }
431
+ const PARTED = new Set([...PARTS.keys()].filter((p) => N[N[p].p].k === "module").map((p) => N[p].p));
432
+ const subsOf = (p) => kids[p].filter((c) => N[c].k === "part");
433
+ // The box a node is drawn in when the map shows the parts of `focus` (a part), or of its module when focus is null.
434
+ function partFor(i, focus) {
435
+ const f = fileOf[i], t = f == null ? null : topPart.get(f);
436
+ if (t == null) return null;
437
+ if (focus == null) return t;
438
+ const ftop = N[N[focus].p].k === "module" ? focus : N[focus].p, s = subPart.get(f);
439
+ if (t !== ftop) return t;
440
+ if (focus === ftop) return s != null ? s : unitOf[i];
441
+ return s != null && s !== focus ? s : unitOf[i];
442
+ }
443
+ const deepestPart = (i) => (fileOf[i] == null ? null : subPart.get(fileOf[i]) ?? topPart.get(fileOf[i]) ?? null);
444
+ // Interface and base methods, and the methods that implement them.
445
+ const implsOf = new Map(), basesOf = new Map();
446
+ for (const [k, s, d] of D.edges) if (k === "overrides") { (implsOf.get(d) || implsOf.set(d, []).get(d)).push(s); (basesOf.get(s) || basesOf.set(s, []).get(s)).push(d); }
447
+ const linkLabel = (L) => (L[6] ? LABEL[L[6].channel] || L[6].channel : LABEL[L[0]]);
448
+ const VIEWS = D.views || [];
449
+ // Each design-pattern label is shown as a view: its nodes, grouped by the role they play.
450
+ const PATTERN_AT = new Map();
451
+ for (const p of D.patterns || []) {
452
+ const lead = p.marks[0], cap = p.pattern[0].toUpperCase() + p.pattern.slice(1);
453
+ const nm = D.nodes[lead.i];
454
+ const owner = (nm.k === "callable" || nm.k === "field") && nm.p != null && D.nodes[nm.p].k === "type" ? D.nodes[nm.p].n + "." : "";
455
+ PATTERN_AT.set(VIEWS.length, p);
456
+ VIEWS.push({ id: p.id, kind: "pattern", title: `${cap}: ${owner}${nm.n}`, narrative: p.rationale, marks: p.marks.map((m) => ({ ...m, note: "" })), legend: {}, pattern: p });
457
+ }
458
+ // Measured coverage: which functions ran under which test.
459
+ const MEAS = D.measured || { t: {}, any: [], watched: [] };
460
+ const RAN_ANY = new Set(MEAS.any), WATCHED = new Set(MEAS.watched), RAN_IN = new Map(), TESTS_OF = new Map();
461
+ for (const [t, ns] of Object.entries(MEAS.t)) { RAN_IN.set(+t, new Set(ns)); for (const n of ns) (TESTS_OF.get(n) || TESTS_OF.set(n, []).get(n)).push(+t); }
462
+ const fileNodeOf = (i) => { let c = i; while (c != null && N[c].k !== "file") c = N[c].p; return c; };
463
+ function measuredHtml(i) {
464
+ if (!MEAS.any.length || N[i].k !== "callable" || !WATCHED.has(fileNodeOf(i))) return "";
465
+ const ts = TESTS_OF.get(i) || [];
466
+ if (!RAN_ANY.has(i)) return `<h3>Measured</h3><p class="note"><span class="tag risk-medium">did not run</span> No test ran this function when coverage was measured.</p>`;
467
+ if (!ts.length) return `<h3>Measured</h3><p class="note">It ran when coverage was measured. The coverage file does not say under which test.</p>`;
468
+ return listHtml("Ran under these tests (measured)", ts.slice(0, 25).map((t) => ({ go: t, name: N[t].n, sub: N[t].f || "", ct: "" })), ts.length);
469
+ }
470
+ // Field access: which functions read and assign each field.
471
+ const ACC_BY_FIELD = new Map(), ACC_BY_FN = new Map();
472
+ for (const a of D.access || []) { (ACC_BY_FIELD.get(a[1]) || ACC_BY_FIELD.set(a[1], []).get(a[1])).push(a); (ACC_BY_FN.get(a[0]) || ACC_BY_FN.set(a[0], []).get(a[0])).push(a); }
473
+ function accessHtml(i) {
474
+ const n = N[i]; let h = "";
475
+ const fnRow = (a) => ({ go: a[0], name: N[a[0]].k === "test" ? N[a[0]].n : qual(a[0]), sub: [N[moduleOf[a[0]]] ? "in " + N[moduleOf[a[0]]].n : "", a[4] ? "line " + a[4] : "", a[5] ? "only while creating the object" : "", a[6] ? "guessed by name" : ""].filter(Boolean).join(" · "), ct: a[3] > 1 ? a[3] + "×" : "" });
476
+ const fieldRow = (a) => ({ go: a[1], name: qual(a[1]), sub: [a[5] ? "only while creating the object" : "", a[6] ? "guessed by name" : ""].filter(Boolean).join(" · "), ct: a[3] > 1 ? a[3] + "×" : "" });
477
+ if (n.k === "field") {
478
+ const all = ACC_BY_FIELD.get(i) || [], w = all.filter((a) => a[2]), r = all.filter((a) => !a[2]);
479
+ if (all.length) { h += listHtml("Assigned by", w.slice(0, 40).map(fnRow), w.length, "Nothing in the repository assigns it after its declaration."); h += listHtml("Read by", r.slice(0, 40).map(fnRow), r.length, "Nothing in the repository reads it."); }
480
+ } else if (n.k === "callable" || n.k === "test") {
481
+ const all = ACC_BY_FN.get(i) || [], w = all.filter((a) => a[2]), r = all.filter((a) => !a[2]);
482
+ h += listHtml("Assigns", w.slice(0, 30).map(fieldRow), w.length) + listHtml("Reads", r.slice(0, 30).map(fieldRow), r.length);
483
+ } else if (n.k === "type") {
484
+ const rows = [];
485
+ N.forEach((f, k) => { if (f.k !== "field" || f.p !== i) return; const all = ACC_BY_FIELD.get(k) || []; if (!all.length) return;
486
+ const w = all.filter((a) => a[2] && !a[5]), r = all.filter((a) => !a[2]);
487
+ const out = [...new Set(w.map((a) => { let c = N[a[0]].p; while (c != null && N[c].k !== "type") c = N[c].p; return c; }).filter((c) => c !== i).map((c) => (c == null ? "top-level code" : N[c].n)))];
488
+ rows.push({ go: k, name: f.n, sub: out.length ? "assigned from " + out.slice(0, 4).join(", ") + (out.length > 4 ? " and " + (out.length - 4) + " more" : "") : "", ct: `${w.length} set · ${r.length} read`, o: out.length, w: w.length }); });
489
+ rows.sort((a, b) => b.o - a.o || b.w - a.w);
490
+ h += listHtml("State", rows.slice(0, 30), rows.length);
491
+ }
492
+ return h;
493
+ }
494
+ const patternsOf = (i) => { const out = []; for (const [k, p] of PATTERN_AT) if (p.marks.some((m) => m.i === i || (N[m.i] && N[m.i].p === i))) out.push(k); return out; };
495
+ const patternRows = (ks) => ks.length ? `<h3>Design patterns <span>${ks.length}</span></h3><ul class="list">${ks.slice(0, 12).map((k) => `<li><button data-view="${k}"><span class="nm">${esc(VIEWS[k].title)}<small>${esc(VIEWS[k].narrative)}</small></span><span class="ct">${Math.round(PATTERN_AT.get(k).c * 100)}%</span></button></li>`).join("")}</ul>${ks.length > 12 ? `<div class="more">and ${ks.length - 12} more</div>` : ""}` : "";
496
+ // Box roles, strongest first. The first group is what a check found; the rest is what a plan expects.
497
+ const ROLE_ORDER = ["drift", "missed", "done", "done_new", "helper", "test_changed", "changed", "new", "contract", "must_edit", "test", "direct", "indirect"];
498
+ const REVIEW_CLS = { "edited as predicted": "c0", "new, as declared": "c2", "edited, not predicted": "c3", "predicted, not edited": "c1" };
499
+ // In the words leyline.md uses for the same things.
500
+ const ROLE_LABEL = { changed: "will change", new: "new code goes in here", contract: "must be edited with it (an interface)", must_edit: "must be edited with it",
501
+ direct: "reached, no edit needed", test: "existing test that runs through it", indirect: "reached further out, no edit needed",
502
+ done: "changed, as planned", done_new: "new, as planned", helper: "helper added, only planned code calls it", test_changed: "test added or changed",
503
+ drift: "changed, but not in the spec", missed: "expected to change, and did not" };
504
+ const FLOWS = D.flows || [];
505
+ const flowsOf = new Map();
506
+ FLOWS.forEach((f, fi) => { for (const st of f.steps) { if (!flowsOf.has(st[0])) flowsOf.set(st[0], []); flowsOf.get(st[0]).push(fi); } });
507
+ const entryTargets = new Set(D.edges.filter((e) => e[0] === "exposes").map((e) => e[2]));
508
+ const entryOf = new Map(D.edges.filter((e) => e[0] === "exposes").map((e) => [e[2], e[1]]));
509
+ const hasEntry = new Set();
510
+ for (const [t, e] of entryOf) if ((N[e].x || {}).trigger !== "lifecycle") for (let c = t; c != null; c = N[c].p) hasEntry.add(c);
511
+ const repoIdx = REPOS[0];
512
+ // A test written as a function is counted with the tests, not the functions, as `leyline map` counts them.
513
+ const counted = (n, kind) => n.k === kind && !(kind === "callable" && (n.x || {}).is_test);
514
+ const count = (root, kind) => { let c = 0; const st = [root]; while (st.length) { const i = st.pop(); if (i !== root && counted(N[i], kind)) c++; st.push(...kids[i]); } return c; };
515
+ const linesOf = (i) => { let c = 0; const st = [i]; while (st.length) { const j = st.pop(); if (N[j].k === "file") c += N[j].b || 0; else st.push(...kids[j]); } return c; };
516
+ const isBody = (i) => N[i].n === "<module>" || N[i].n === "<top-level>";
517
+ const big = (n) => (n >= 100000 ? Math.round(n / 1000) + "k" : n >= 10000 ? (n / 1000).toFixed(1) + "k" : n.toLocaleString());
518
+ const short = (i) => {
519
+ const n = N[i];
520
+ if (isBody(i)) return "script body";
521
+ if (n.k === "callable") { const m = /\(([^)]*)\)$/.exec(n.i); const a = m && m[1] ? m[1].split(",").length : 0; return n.n === ".ctor" ? `new(${a ? "…" : ""})` : `${n.n}(${a ? "…" : ""})`; }
522
+ return n.n;
523
+ };
524
+ const langOf = (i) => { const n = N[i]; if (n.l) return n.l; if (n.k === "module") { const ls = new Set(kids[i].map((f) => N[f].l).filter(Boolean)); return ls.size === 1 ? [...ls][0] : "none"; } return "none"; };
525
+
526
+ /* ---------- state ---------- */
527
+ const S = { level: WS ? "ws" : "repo", focus: WS ? null : repoIdx, sel: null, hover: null, mode: "map", externals: false, view: null,
528
+ change: null, phase: "plan", pick: null,
529
+ filters: { ws: { calls: true, types: true, inherit: true, imports: true, channels: true },
530
+ repo: { calls: true, types: true, inherit: true, imports: true, channels: true },
531
+ module: { calls: true, types: false, inherit: true, imports: false, channels: true },
532
+ system: { calls: true, types: false, inherit: true, imports: false, channels: true },
533
+ part: { calls: true, types: false, inherit: true, imports: false, channels: true },
534
+ unit: { calls: true, types: false, inherit: true, imports: false, channels: true },
535
+ view: { calls: true, types: false, inherit: true, imports: false, channels: true } },
536
+ flow: 0, viewIdx: 0, prev: null,
537
+ outside: { repo: true, module: false, system: true, part: true, unit: true, view: true } };
538
+ const cam = { x: 0, y: 0, k: 1 };
539
+
540
+ /* ---------- build the graph for the current level ---------- */
541
+ // The current saved view: which box each marked node falls in, and that box's strongest role.
542
+ let VW = null;
543
+ function viewSetup() {
544
+ const v = curView(); VW = { boxes: new Map(), marksIn: new Map(), roles: [] };
545
+ if (!v) return;
546
+ const custom = [...new Set(v.marks.map((m) => m.role))].filter((r) => !ROLE_ORDER.includes(r));
547
+ VW.roles = [...ROLE_ORDER.filter((r) => v.marks.some((m) => m.role === r)), ...custom];
548
+ VW.cls = (r) => (ROLE_ORDER.includes(r) ? r : REVIEW_CLS[r] || "c" + (custom.indexOf(r) % 6));
549
+ const rankOf = (r) => VW.roles.indexOf(r);
550
+ let marks = v.marks;
551
+ const boxOf = (i) => (unitOf[i] != null ? unitOf[i] : i);
552
+ if (new Set(marks.map((m) => boxOf(m.i))).size > 70) { marks = marks.filter((m) => m.role !== "indirect"); VW.trimmed = true; }
553
+ for (const m of marks) { const b = boxOf(m.i); (VW.marksIn.get(b) || VW.marksIn.set(b, []).get(b)).push(m); if (!VW.boxes.has(b) || rankOf(m.role) < rankOf(VW.boxes.get(b))) VW.boxes.set(b, m.role); }
554
+ }
555
+ function rep(i) {
556
+ const n = N[i];
557
+ if (S.level === "view") { const b = unitOf[i] != null ? unitOf[i] : i; return VW.boxes.has(b) ? b : null; }
558
+ if (S.level === "ws") return n.k === "external" ? null : repoOf[i];
559
+ if (S.level === "repo") {
560
+ if (n.k === "external") return S.externals && repoOf[i] === S.focus ? i : null;
561
+ const m = n.k === "module" ? i : moduleOf[i];
562
+ return m != null && (repoOf[m] === S.focus || S.outside.repo) ? m : null;
563
+ }
564
+ if (n.k === "external") return null;
565
+ if (n.k === "system") return null;
566
+ if (S.level === "module") return moduleOf[i] === S.focus ? (unitOf[i] == null ? null : PARTED.has(S.focus) ? partFor(i, null) ?? grp(unitOf[i]) : grp(unitOf[i])) : (n.k === "module" ? i : moduleOf[i]);
567
+ if (S.level === "part") { if (unitOf[i] == null) return n.k === "module" ? i : null; return moduleOf[i] === moduleOf[S.focus] ? partFor(i, S.focus) ?? unitOf[i] : moduleOf[i]; }
568
+ if (S.level === "system") { const u = unitOf[i]; if (u == null) return n.k === "module" ? i : null; return sysOf.get(u) === S.focus ? u : moduleOf[i] === moduleOf[S.focus] ? grp(u) : moduleOf[i]; }
569
+ const u = unitOf[i];
570
+ if (u === S.focus) { const m = memberOf(i); return m != null && N[m].k !== "field" && N[m].k !== "entry_point" ? m : null; }
571
+ return u;
572
+ }
573
+ function inside(i) {
574
+ if (S.level === "view") return VW.boxes.has(i);
575
+ if (S.level === "ws") return N[i].k === "repo";
576
+ if (S.level === "repo") return (N[i].k === "module" || N[i].k === "external") && repoOf[i] === S.focus;
577
+ if (S.level === "module" && PARTED.has(S.focus)) return N[i].k === "part" && N[i].p === S.focus;
578
+ if (S.level === "module") return N[i].k === "system" ? N[i].p === S.focus : moduleOf[i] === S.focus && unitOf[i] === i && !sysOf.has(i);
579
+ if (S.level === "part") return subsOf(S.focus).length ? N[i].k === "part" && N[i].p === S.focus : unitOf[i] === i && PARTS.get(S.focus).has(fileOf[i]);
580
+ if (S.level === "system") return unitOf[i] === i && sysOf.get(i) === S.focus;
581
+ return unitOf[i] === S.focus && memberOf(i) === i;
582
+ }
583
+ function buildView() {
584
+ const edges = new Map();
585
+ const used = new Set();
586
+ for (const L of LINKS) {
587
+ if (!S.filters[S.level][GROUPS[L[0]]]) continue;
588
+ const a = rep(L[1]), b = rep(L[2]);
589
+ if (a == null || b == null || a === b) continue;
590
+ const ia = inside(a), ib = inside(b);
591
+ if (!ia && !ib) continue;
592
+ if (S.level !== "ws" && !S.outside[S.level] && !(ia && ib)) continue;
593
+ const comm = L[0] === "communicates";
594
+ const key = a + ">" + b + (comm ? ">c" : "");
595
+ let e = edges.get(key);
596
+ if (!e) edges.set(key, e = { key, a, b, n: 0, exact: 0, guess: 0, kinds: {}, links: [], comm, chans: new Set(), cross: WS && repoOf[a] !== repoOf[b] });
597
+ e.n += L[4]; if (L[3] === 2) e.exact += L[4]; if (L[3] === 0) e.guess += L[4];
598
+ if (comm) e.chans.add(L[6].channel);
599
+ e.kinds[linkLabel(L)] = (e.kinds[linkLabel(L)] || 0) + L[4];
600
+ e.links.push(L);
601
+ used.add(a); used.add(b);
602
+ }
603
+ const nodes = [];
604
+ if (S.level === "view") { for (const b of VW.boxes.keys()) nodes.push(b); }
605
+ else if (S.level === "ws") { for (const r of REPOS) nodes.push(r); }
606
+ else if (S.level === "repo") { N.forEach((n, i) => { if (repoOf[i] === S.focus && (n.k === "module" || (S.externals && n.k === "external" && used.has(i)))) nodes.push(i); }); }
607
+ else if (S.level === "module" && PARTED.has(S.focus)) { for (const c of subsOf(S.focus)) nodes.push(c); }
608
+ else if (S.level === "part") {
609
+ const subs = subsOf(S.focus), fs = PARTS.get(S.focus);
610
+ if (subs.length) nodes.push(...subs);
611
+ else N.forEach((n, i) => { if (unitOf[i] === i && fs.has(fileOf[i]) && (n.k === "type" || kids[i].some((c) => N[c].k === "callable"))) nodes.push(i); });
612
+ }
613
+ else if (S.level === "module") { N.forEach((n, i) => { if (n.k === "system" ? n.p === S.focus : moduleOf[i] === S.focus && unitOf[i] === i && !sysOf.has(i) && (n.k === "type" || kids[i].some((c) => N[c].k === "callable"))) nodes.push(i); }); }
614
+ else if (S.level === "system") { for (const u of unitsOf.get(S.focus) || []) nodes.push(u); }
615
+ else { for (const c of kids[S.focus]) if (N[c].k === "callable" || N[c].k === "type") nodes.push(c); }
616
+ const own = new Set(nodes);
617
+ // Outside nodes: keep the heaviest, so a busy type does not drown the picture.
618
+ const weight = new Map();
619
+ for (const e of edges.values()) for (const s of [e.a, e.b]) if (!own.has(s)) weight.set(s, (weight.get(s) || 0) + e.n);
620
+ const stubs = [...weight.entries()].sort((x, y) => y[1] - x[1]);
621
+ const keep = new Set(stubs.slice(0, S.level === "repo" ? 24 : 12).map((s) => s[0]));
622
+ for (const [key, e] of edges) if ((!own.has(e.a) && !keep.has(e.a)) || (!own.has(e.b) && !keep.has(e.b))) edges.delete(key);
623
+ for (const s of keep) nodes.push(s);
624
+ return { nodes, own, edges: [...edges.values()], hiddenStubs: Math.max(0, stubs.length - keep.size) };
625
+ }
626
+
627
+ /* ---------- layout: layered, dependents above what they depend on ---------- */
628
+ const meter = document.createElement("canvas").getContext("2d");
629
+ function measure(text, font) { meter.font = font; return meter.measureText(text).width; }
630
+ // The level's key in the remembered layout export.py writes; levels without one are laid out here.
631
+ const levelKey = () => (S.level === "ws" ? "ws" : S.level === "repo" ? "repo:" + N[S.focus].i : S.level === "module" ? "module:" + N[S.focus].i : null);
632
+ const MONO = '500 12.5px "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace', SANS = '11px "IBM Plex Sans", "Segoe UI", system-ui, sans-serif';
633
+ function layout(view) {
634
+ const two = S.level === "ws" || S.level === "repo" || S.level === "view" || ((S.level === "module" || S.level === "part") && view.nodes.some((i) => N[i].k === "system" || N[i].k === "part"));
635
+ const boxes = new Map();
636
+ const homeMod = S.level === "module" ? S.focus : S.level === "repo" || S.level === "view" || S.level === "ws" ? null : moduleOf[S.focus];
637
+ const homeRepo = S.level === "repo" ? S.focus : homeMod;
638
+ for (const i of view.nodes) {
639
+ const far = homeMod != null && N[i].k !== "module" && moduleOf[i] !== homeMod;
640
+ const away = S.level !== "view" && otherRepo(i, homeRepo);
641
+ const label = (away ? N[repoOf[i]].n + " / " : "") + (far ? N[moduleOf[i]].n + " / " : "") + short(i);
642
+ const meta = two ? metaLine(i) : "";
643
+ const w = Math.max(measure(label, MONO) + 34, two ? measure(meta, SANS) + 32 : 0, 70);
644
+ boxes.set(i, { i, w: Math.ceil(w), h: two ? 46 : 30, label, meta, x: 0, y: 0, stub: !view.own.has(i), away });
645
+ }
646
+ const fixed = D.layout && levelKey() ? D.layout[levelKey()] : null;
647
+ if (fixed && view.nodes.some((i) => fixed[i])) return placeFixed(view, boxes, fixed, two);
648
+ const out = new Map(), inn = new Map();
649
+ for (const i of view.nodes) { out.set(i, []); inn.set(i, []); }
650
+ // A handler depends on whatever raises its event, so event arrows are layered against their direction.
651
+ for (const e of view.edges) { const flip = e.comm && e.chans.has("event"); const a = flip ? e.b : e.a, b = flip ? e.a : e.b; if (!out.get(a).includes(b)) { out.get(a).push(b); inn.get(b).push(a); } }
652
+ const linked = view.nodes.filter((i) => out.get(i).length || inn.get(i).length);
653
+ const loose = view.nodes.filter((i) => !out.get(i).length && !inn.get(i).length);
654
+ // Break cycles with a depth-first pass, then layer by longest path.
655
+ const state = new Map(), back = new Set();
656
+ const order = [...linked].sort((a, b) => inn.get(a).length - inn.get(b).length || out.get(b).length - out.get(a).length);
657
+ const visit = (v) => { state.set(v, 1); for (const w of out.get(v)) { if (state.get(w) === 1) back.add(v + ">" + w); else if (!state.get(w)) visit(w); } state.set(v, 2); };
658
+ for (const v of order) if (!state.get(v)) visit(v);
659
+ const layer = new Map();
660
+ const depth = (v) => { if (layer.has(v)) return layer.get(v); layer.set(v, 0); let d = 0; for (const u of inn.get(v)) if (!back.has(u + ">" + v)) d = Math.max(d, depth(u) + 1); layer.set(v, d); return d; };
661
+ linked.forEach(depth);
662
+ const layers = [];
663
+ for (const v of linked) (layers[layer.get(v)] = layers[layer.get(v)] || []).push(v);
664
+ const pos = new Map();
665
+ const sweep = (rows, up, down, times) => {
666
+ rows.forEach((r) => r.forEach((v, j) => pos.set(v, j)));
667
+ for (let k = 0; k < times; k++) {
668
+ const seq = k % 2 ? [...rows.keys()].reverse() : [...rows.keys()];
669
+ for (const r of seq) { const nb = k % 2 ? down : up; const b = new Map(rows[r].map((v) => { const ns = (nb.get(v) || []).filter((u) => pos.has(u)); return [v, ns.length ? ns.reduce((t, u) => t + pos.get(u), 0) / ns.length : pos.get(v)]; })); rows[r].sort((x, y) => b.get(x) - b.get(y)); rows[r].forEach((v, j) => pos.set(v, j)); }
670
+ }
671
+ };
672
+ const dense = layers.filter(Boolean);
673
+ const rankLayer = new Map(); dense.forEach((r, k) => r.forEach((v) => rankLayer.set(v, k)));
674
+ const upL = new Map(), downL = new Map();
675
+ for (const v of linked) { upL.set(v, [...inn.get(v), ...out.get(v)].filter((u) => rankLayer.get(u) < rankLayer.get(v))); downL.set(v, [...inn.get(v), ...out.get(v)].filter((u) => rankLayer.get(u) > rankLayer.get(v))); }
676
+ sweep(dense, upL, downL, 4);
677
+ // Wide layers wrap into several ranks, so the map stays readable on narrow screens.
678
+ const stageW = svg.getBoundingClientRect().width || 1000;
679
+ const MAXW = Math.max(380, Math.min(1500, stageW / 0.78)), GX = 26, GY = two ? 84 : 62, rowH = two ? 46 : 30;
680
+ const ranks = [];
681
+ for (const r of dense) { let line = [], lw = 0; for (const v of r) { const b = boxes.get(v); if (line.length && lw + b.w > MAXW) { ranks.push(line); line = []; lw = 0; } line.push(v); lw += b.w + GX; } if (line.length) ranks.push(line); }
682
+ const rank = new Map(); ranks.forEach((r, k) => r.forEach((v) => rank.set(v, k)));
683
+ // An edge that skips ranks gets a waypoint in each rank it crosses, so it runs between boxes, not behind them.
684
+ const up = new Map(), down = new Map();
685
+ const link = (a, b) => { (down.get(a) || down.set(a, []).get(a)).push(b); (up.get(b) || up.set(b, []).get(b)).push(a); };
686
+ for (const e of view.edges) {
687
+ e.chain = [];
688
+ const ra = rank.get(e.a), rb = rank.get(e.b);
689
+ if (ra == null || rb == null || ra === rb) continue;
690
+ const lo = Math.min(ra, rb), hi = Math.max(ra, rb), topNode = ra < rb ? e.a : e.b, botNode = ra < rb ? e.b : e.a;
691
+ let prev = topNode; const ids = [];
692
+ for (let r = lo + 1; r < hi; r++) { const id = "~" + e.key + "~" + r; boxes.set(id, { i: id, w: 2, h: rowH, x: 0, y: 0, dummy: true }); ranks[r].push(id); rank.set(id, r); link(prev, id); prev = id; ids.push(id); }
693
+ link(prev, botNode);
694
+ e.chain = ra < rb ? ids : ids.reverse();
695
+ }
696
+ sweep(ranks, up, down, 8);
697
+ let y = 0;
698
+ // Waypoints sit close together, so bundles of long edges take little room.
699
+ const gapAfter = (list, j) => { if (j === list.length - 1) return 0; const a = boxes.get(list[j]).dummy, b = boxes.get(list[j + 1]).dummy; return a && b ? 5 : a || b ? 12 : GX; };
700
+ const place = (list) => { const w = list.reduce((t, v, j) => t + boxes.get(v).w + gapAfter(list, j), 0); let x = -w / 2; list.forEach((v, j) => { const b = boxes.get(v); b.x = x + b.w / 2; b.y = y; x += b.w + gapAfter(list, j); }); };
701
+ for (const r of ranks) { place(r); y += rowH + GY; }
702
+ let looseY = null;
703
+ if (loose.length) {
704
+ if (linked.length) y += 6; looseY = y - 14; y += 8;
705
+ let line = [], lw = 0;
706
+ for (const v of loose.sort((a, b) => N[a].n.localeCompare(N[b].n))) { const b = boxes.get(v); if (line.length && lw + b.w > MAXW) { place(line); y += rowH + 16; line = []; lw = 0; } line.push(v); lw += b.w + GX; }
707
+ if (line.length) place(line);
708
+ }
709
+ return { boxes, back, looseY, hasLinked: linked.length > 0 };
710
+ }
711
+ // Rows and places from the remembered layout. A box keeps its place unless a wider neighbour needs the room;
712
+ // boxes it does not know (outside packages, other modules) go in a row above what they depend on or below
713
+ // what depends on them; a long edge passes through each row it crosses at a gap between boxes.
714
+ function placeFixed(view, boxes, fixed, two) {
715
+ const GX = 26, GY = two ? 84 : 62, rowH = two ? 46 : 30;
716
+ const at = new Map();
717
+ for (const i of view.nodes) if (fixed[i]) at.set(i, fixed[i]);
718
+ const known = [...at.values()].map((f) => f[0]);
719
+ const top = Math.min(...known), bottom = Math.max(...known);
720
+ for (const i of view.nodes) {
721
+ if (at.has(i)) continue;
722
+ let into = 0, from = 0; const xs = [];
723
+ for (const e of view.edges) { if (e.b === i && fixed[e.a]) { into++; xs.push(fixed[e.a][1]); } if (e.a === i && fixed[e.b]) { from++; xs.push(fixed[e.b][1]); } }
724
+ at.set(i, [from > into ? top - 1 : bottom + 1, xs.length ? xs.reduce((t, x) => t + x, 0) / xs.length : 0]);
725
+ }
726
+ const rowNums = [...new Set([...at.values()].map((f) => f[0]))].sort((a, b) => a - b);
727
+ const rowAt = new Map(rowNums.map((r, k) => [r, k]));
728
+ const rows = rowNums.map(() => []);
729
+ for (const i of view.nodes) rows[rowAt.get(at.get(i)[0])].push(i);
730
+ rows.forEach((list, k) => {
731
+ list.sort((a, b) => at.get(a)[1] - at.get(b)[1] || a - b);
732
+ let right = -Infinity, moved = 0;
733
+ for (const i of list) { const b = boxes.get(i), want = at.get(i)[1] - b.w / 2, left = Math.max(want, right + GX); moved += left - want; b.x = left + b.w / 2; b.y = k * (rowH + GY); right = left + b.w; }
734
+ if (moved && list.every((i) => !fixed[i])) for (const i of list) boxes.get(i).x -= moved / list.length; // a row of newcomers stays centred
735
+ });
736
+ const busy = rows.map((list) => list.map((i) => { const b = boxes.get(i); return [b.x - b.w / 2, b.x + b.w / 2]; }));
737
+ const rowOf = new Map(); rows.forEach((list, k) => list.forEach((i) => rowOf.set(i, k)));
738
+ const taken = new Map();
739
+ for (const e of view.edges) {
740
+ e.chain = [];
741
+ const ra = rowOf.get(e.a), rb = rowOf.get(e.b);
742
+ if (Math.abs(ra - rb) < 2) continue;
743
+ const A = boxes.get(e.a), B = boxes.get(e.b), ids = [];
744
+ for (let r = Math.min(ra, rb) + 1; r < Math.max(ra, rb); r++) {
745
+ let x = A.x + (B.x - A.x) * (r - ra) / (rb - ra);
746
+ for (const [l, rr] of busy[r]) if (x > l - 8 && x < rr + 8) x = x - l < rr - x ? l - 9 : rr + 9;
747
+ const slot = r + ":" + Math.round(x / 8), n = taken.get(slot) || 0; taken.set(slot, n + 1);
748
+ x += (n % 2 ? -1 : 1) * Math.ceil(n / 2) * 4;
749
+ const id = "~" + e.key + "~" + r;
750
+ boxes.set(id, { i: id, w: 2, h: rowH, x, y: r * (rowH + GY), dummy: true });
751
+ ids.push(id);
752
+ }
753
+ e.chain = ra < rb ? ids : ids.reverse();
754
+ }
755
+ return { boxes, back: new Set(), looseY: null, hasLinked: true };
756
+ }
757
+ function metaLine(i) {
758
+ const n = N[i];
759
+ if (n.k === "repo") { const ms = N.filter((m, k) => m.k === "module" && repoOf[k] === i).length; return `${ms} module${ms === 1 ? "" : "s"} · ${linesOf(i).toLocaleString()} lines`; }
760
+ if (S.level === "view") { const ms = VW.marksIn.get(i) || []; const where = n.k === "module" ? "module" : N[moduleOf[i]] ? N[moduleOf[i]].n : ""; return `${where}${ms.length > 1 ? " · " + ms.length + " marked" : ""}`; }
761
+ if (n.k === "external") return (n.x && (n.x.category || n.x.ecosystem)) || "external";
762
+ if (n.k === "system") { const us = unitsOf.get(i) || []; return `${us.length} types and files · ${us.reduce((t, u) => t + count(u, "callable"), 0)} functions`; }
763
+ if (n.k === "part") { const x = n.x || {}; return `${x.files || 0} file${x.files === 1 ? "" : "s"} · ${(x.functions || 0).toLocaleString()} functions · ${big(x.lines || 0)} lines${x.stale ? " · name may be stale" : ""}`; }
764
+ if (n.k !== "module") return kindName(i);
765
+ const f = kids[i].filter((c) => N[c].k === "file").length;
766
+ return `${f} file${f === 1 ? "" : "s"} · ${linesOf(i).toLocaleString()} lines`;
767
+ }
768
+
769
+ /* ---------- draw ---------- */
770
+ let L = null, OVER = null;
771
+ function draw() {
772
+ OVER = S.change != null && S.level !== "view" ? overlay() : null;
773
+ S.view = buildView();
774
+ L = layout(S.view);
775
+ const gE = $("edges"), gN = $("nodes");
776
+ gE.textContent = ""; gN.textContent = "";
777
+ const maxN = Math.max(1, ...S.view.edges.map((e) => e.n));
778
+ svg.classList.toggle("dense", S.view.edges.length > 45);
779
+ // Spread the ends of edges along each box so arrowheads do not pile up on one point.
780
+ const ports = new Map(), ends = new Map();
781
+ const slot = (box, side, otherX, key) => { const k = box.i + "|" + side; if (!ports.has(k)) ports.set(k, []); ports.get(k).push([otherX, key]); };
782
+ for (const e of S.view.edges) {
783
+ const A = L.boxes.get(e.a), B = L.boxes.get(e.b);
784
+ const first = e.chain.length ? L.boxes.get(e.chain[0]) : B, last = e.chain.length ? L.boxes.get(e.chain[e.chain.length - 1]) : A;
785
+ if (first.y === A.y && !e.chain.length) continue;
786
+ const sa = first.y > A.y ? "b" : "t", sb = last.y < B.y ? "t" : "b";
787
+ slot(A, sa, first.x, e.key + "|a"); slot(B, sb, last.x, e.key + "|b"); ends.set(e.key, [sa, sb]);
788
+ }
789
+ const off = new Map();
790
+ for (const [k, list] of ports) { list.sort((p, q) => p[0] - q[0]); const box = L.boxes.get(isNaN(+k.split("|")[0]) ? k.split("|")[0] : +k.split("|")[0]); const span = Math.min(box.w * 0.7, 18 * (list.length - 1)); list.forEach(([, key], j) => off.set(key, list.length > 1 ? -span / 2 + (span * j) / (list.length - 1) : 0)); }
791
+ for (const e of S.view.edges) {
792
+ const A = L.boxes.get(e.a), B = L.boxes.get(e.b);
793
+ const g = el("g", { class: "edge" + (e.comm ? " comm" : e.guess === e.n ? " guess" : e.exact < e.n ? " heur" : "") + (e.cross ? " cross" : ""), "data-key": e.key, "data-comm": e.comm ? 1 : 0, "data-cross": e.cross ? 1 : 0 }, gE);
794
+ const d = route(A, B, e.chain.map((id) => L.boxes.get(id)), ends.get(e.key), off.get(e.key + "|a") || 0, off.get(e.key + "|b") || 0);
795
+ el("path", { class: "vis", d: d.path, "stroke-width": e.comm ? 1.8 : (1 + 2.2 * Math.log(1 + e.n) / Math.log(1 + maxN)).toFixed(2), "marker-end": `url(#arrow${e.comm ? "-c" : e.cross ? "-x" : ""})` }, g);
796
+ el("path", { class: "hit", d: d.path }, g);
797
+ if (e.comm || S.level === "repo" || S.level === "ws" || S.view.edges.length <= 14) { const t = el("text", { x: d.mx, y: d.my + 4, "text-anchor": "middle" }, g); t.textContent = e.comm ? [...e.chans].join(", ") : e.n; }
798
+ g.addEventListener("click", (ev) => { ev.stopPropagation(); select({ edge: e.key }); });
799
+ }
800
+ if (L.looseY != null && L.hasLinked) { const t = el("text", { class: "group-label", x: 0, y: L.looseY, "text-anchor": "middle" }, gN); t.textContent = "No links in this view"; }
801
+ for (const b of L.boxes.values()) {
802
+ if (b.dummy) continue;
803
+ const n = N[b.i];
804
+ const role = S.level === "view" ? VW.boxes.get(b.i) : OVER ? OVER.get(b.i) : null;
805
+ const g = el("g", { class: "node" + (b.stub ? " stub" : "") + (b.away ? " other" : "") + (role ? " role-" + (S.level === "view" ? VW.cls(role) : role) : ""), transform: `translate(${b.x - b.w / 2},${b.y})`, tabindex: 0, "data-i": b.i, role: "button", "aria-label": n.n }, gN);
806
+ el("rect", { width: b.w, height: b.h, rx: 5 }, g);
807
+ el("rect", { class: "lang-" + langOf(b.i), x: 0, y: 0, width: 4, height: b.h, rx: 2, style: "stroke:none" }, g);
808
+ const t = el("text", { x: 14, y: b.meta ? 19 : 19.5 }, g); t.textContent = b.label;
809
+ if (b.meta) { const m = el("text", { class: "meta", x: 14, y: 35 }, g); m.textContent = b.meta; }
810
+ if (hasEntry.has(b.i)) el("path", { class: "flag", d: `M${b.w - 14} 7h8l-2.5 3.5l2.5 3.5h-8z`, style: "stroke:none" }, g);
811
+ g.addEventListener("click", (ev) => { ev.stopPropagation(); select({ node: b.i }); });
812
+ g.addEventListener("pointerenter", () => { S.hover = b.i; mark(); });
813
+ g.addEventListener("pointerleave", () => { S.hover = null; mark(); });
814
+ g.addEventListener("dblclick", (ev) => { ev.stopPropagation(); if (S.mode === "views" || S.mode === "change") goTo(b.i); else open(b.i); });
815
+ g.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.shiftKey ? open(b.i) : select({ node: b.i }); } });
816
+ }
817
+ mark();
818
+ if (S.mode === "matrix") drawMatrix();
819
+ }
820
+ function route(A, B, chain, sides, o1, o2) {
821
+ if (!sides) { // same rank: bow over the top
822
+ const right = B.x > A.x;
823
+ const x1 = A.x + (right ? A.w / 2 : -A.w / 2), y1 = A.y + A.h / 2, x2 = B.x + (right ? -B.w / 2 - 2 : B.w / 2 + 2), y2 = B.y + B.h / 2;
824
+ const bow = -Math.min(60, 18 + Math.abs(x2 - x1) * 0.12);
825
+ const c1 = [x1 + (x2 - x1) * 0.3, y1 + bow], c2 = [x1 + (x2 - x1) * 0.7, y2 + bow];
826
+ return { path: `M${x1} ${y1}C${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${x2} ${y2}`, mx: (x1 + 3 * c1[0] + 3 * c2[0] + x2) / 8, my: (y1 + 3 * c1[1] + 3 * c2[1] + y2) / 8 };
827
+ }
828
+ const dir = sides[0] === "b" ? 1 : -1;
829
+ // Waypoints: leave A, pass straight through each crossed rank, arrive at B.
830
+ const pts = [[A.x + o1, dir > 0 ? A.y + A.h : A.y]];
831
+ for (const d of chain) { pts.push([d.x, dir > 0 ? d.y - 4 : d.y + d.h + 4]); pts.push([d.x, dir > 0 ? d.y + d.h + 4 : d.y - 4]); }
832
+ pts.push([B.x + o2, sides[1] === "t" ? B.y - 2 : B.y + B.h + 2]);
833
+ let path = `M${pts[0][0]} ${pts[0][1]}`, mx = 0, my = 0;
834
+ for (let k = 1; k < pts.length; k++) {
835
+ const [x1, y1] = pts[k - 1], [x2, y2] = pts[k];
836
+ if (k % 2 === 0) { path += `L${x2} ${y2}`; continue; } // straight through a crossed rank
837
+ const kk = Math.max(18, Math.abs(y2 - y1) * 0.45) * dir;
838
+ path += `C${x1} ${y1 + kk} ${x2} ${y2 - kk} ${x2} ${y2}`;
839
+ if (k === 1) { mx = (x1 + x2) / 2; my = (y1 + y2) / 2; }
840
+ }
841
+ return { path, mx, my };
842
+ }
843
+ function mark() {
844
+ const sel = S.sel || {};
845
+ const near = new Set(), nearE = new Set();
846
+ const ref = S.hover != null ? S.hover : sel.node;
847
+ if (ref != null) for (const e of S.view.edges) if (e.a === ref || e.b === ref) { near.add(e.a); near.add(e.b); nearE.add(e.key); }
848
+ if (sel.edge && S.hover == null) { const e = S.view.edges.find((x) => x.key === sel.edge); if (e) { near.add(e.a); near.add(e.b); } }
849
+ const focusOn = (ref != null && L.boxes.has(ref)) || (!!sel.edge && S.hover == null);
850
+ // A task or scenario picked on the change page: its boxes stand out, the rest fade.
851
+ const pick = picked(), pickOn = !focusOn && pick && [...L.boxes.keys()].some((i) => pick.has(i));
852
+ for (const g of $("nodes").querySelectorAll(".node")) {
853
+ const i = +g.dataset.i;
854
+ g.classList.toggle("sel", sel.node === i);
855
+ g.classList.toggle("near", sel.node !== i && (near.has(i) || ref === i));
856
+ g.classList.toggle("picked", !!pickOn && pick.has(i));
857
+ g.classList.toggle("dim", focusOn ? ref !== i && sel.node !== i && !near.has(i) : !!pickOn && !pick.has(i));
858
+ }
859
+ for (const g of $("edges").querySelectorAll(".edge")) {
860
+ const k = g.dataset.key, isSel = sel.edge === k, isNear = nearE.has(k);
861
+ const e = pickOn ? S.view.edges.find((x) => x.key === k) : null;
862
+ g.classList.toggle("sel", isSel); g.classList.toggle("near", isNear);
863
+ g.classList.toggle("dim", focusOn ? !isSel && !isNear : !!e && !pick.has(e.a) && !pick.has(e.b));
864
+ g.querySelector(".vis").setAttribute("marker-end", `url(#arrow${isSel ? "-m" : g.dataset.comm === "1" ? "-c" : g.dataset.cross === "1" ? "-x" : isNear ? "-a" : ""})`);
865
+ }
866
+ for (const td of $("matrix").querySelectorAll("td.on")) td.classList.toggle("sel", td.dataset.key === sel.edge);
867
+ }
868
+
869
+ /* ---------- camera ---------- */
870
+ const svg = $("map"), world = $("world");
871
+ const apply = () => world.setAttribute("transform", `translate(${cam.x},${cam.y}) scale(${cam.k})`);
872
+ function fit() {
873
+ const r = svg.getBoundingClientRect();
874
+ if (!r.width || !L.boxes.size) return;
875
+ let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9;
876
+ for (const b of L.boxes.values()) { x0 = Math.min(x0, b.x - b.w / 2); x1 = Math.max(x1, b.x + b.w / 2); y0 = Math.min(y0, b.y); y1 = Math.max(y1, b.y + b.h); }
877
+ const padT = $("tools").getBoundingClientRect().height + 36, padB = Math.max($("hint").getBoundingClientRect().height, $("viewkey").getBoundingClientRect().height) + 28, pad = 20;
878
+ cam.k = Math.min(1.25, (r.width - 2 * pad) / (x1 - x0), (r.height - padT - padB) / (y1 - y0));
879
+ cam.k = Math.max(cam.k, 0.3);
880
+ cam.x = r.width / 2 - ((x0 + x1) / 2) * cam.k;
881
+ cam.y = padT + Math.max(0, (r.height - padT - padB - (y1 - y0) * cam.k) / 2) - y0 * cam.k;
882
+ apply();
883
+ }
884
+ function zoomAt(px, py, f) { const k = Math.min(3, Math.max(0.1, cam.k * f)); cam.x = px - (px - cam.x) * (k / cam.k); cam.y = py - (py - cam.y) * (k / cam.k); cam.k = k; apply(); }
885
+ svg.addEventListener("wheel", (e) => { e.preventDefault(); const r = svg.getBoundingClientRect(); zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0015)); }, { passive: false });
886
+ const ptrs = new Map(); let pinch = 0, moved = 0;
887
+ svg.addEventListener("pointerdown", (e) => { ptrs.set(e.pointerId, [e.clientX, e.clientY]); moved = 0; });
888
+ svg.addEventListener("pointermove", (e) => {
889
+ if (!ptrs.has(e.pointerId)) return;
890
+ const p = ptrs.get(e.pointerId), dx = e.clientX - p[0], dy = e.clientY - p[1];
891
+ if (ptrs.size === 1) { moved += Math.abs(dx) + Math.abs(dy); if (moved > 4) { svg.classList.add("dragging"); svg.setPointerCapture(e.pointerId); cam.x += dx; cam.y += dy; apply(); } }
892
+ ptrs.set(e.pointerId, [e.clientX, e.clientY]);
893
+ if (ptrs.size === 2) { const [a, b] = [...ptrs.values()]; const d = Math.hypot(a[0] - b[0], a[1] - b[1]); const r = svg.getBoundingClientRect(); if (pinch) zoomAt((a[0] + b[0]) / 2 - r.left, (a[1] + b[1]) / 2 - r.top, d / pinch); pinch = d; }
894
+ });
895
+ const endPtr = (e) => { ptrs.delete(e.pointerId); pinch = 0; svg.classList.remove("dragging"); };
896
+ svg.addEventListener("pointerup", endPtr); svg.addEventListener("pointercancel", endPtr);
897
+ svg.addEventListener("click", () => { if (moved < 5) select(null); });
898
+
899
+ /* ---------- navigation ---------- */
900
+ function select(sel) { S.sel = sel; mark(); side(); }
901
+ function setLevel(level, focus, sel) { S.level = level; S.focus = focus; S.sel = sel || null; render(true); }
902
+ function open(i) {
903
+ const n = N[i];
904
+ if (n.k === "repo") return setLevel("repo", i);
905
+ if (n.k === "module") return setLevel("module", i);
906
+ if (n.k === "system") return setLevel("system", i);
907
+ if (n.k === "part") return setLevel("part", i);
908
+ if (n.k === "external") return;
909
+ const u = unitOf[i];
910
+ if (u === i) return setLevel("unit", i);
911
+ if (N[i].k === "type") return goTo(i);
912
+ select({ node: i });
913
+ }
914
+ function goTo(i) {
915
+ const n = N[i];
916
+ if (n.k === "test") { const fi = FLOWS.findIndex((f) => f.entry === i); if (fi >= 0) return openFlow(fi, null); }
917
+ if (S.mode === "views" || S.mode === "change") setMode("map");
918
+ if (S.mode === "flows") { if (FLOWS[S.flow] && FLOWS[S.flow].steps.some((s) => s[0] === i)) { S.sel = { node: i }; drawFlow(); side(); const cur = $("flowview").querySelector("[aria-current]"); if (cur) cur.scrollIntoView({ block: "center" }); return; } setMode("map"); }
919
+ if (n.k === "repo") return setLevel("repo", i);
920
+ if (n.k === "module") return setLevel("repo", repoOf[i], { node: i });
921
+ if (n.k === "external") { S.externals = true; return setLevel("repo", repoOf[i], { node: i }); }
922
+ if (n.k === "entry_point") { const t = D.edges.find((e) => e[0] === "exposes" && e[1] === i); if (t) return goTo(t[2]); }
923
+ if (n.k === "system") return setLevel("module", n.p, { node: i });
924
+ if (n.k === "part") return setLevel(N[n.p].k === "module" ? "module" : "part", n.p, { node: i });
925
+ const u = unitOf[i];
926
+ if (u === i && PARTED.has(moduleOf[i]) && deepestPart(i) != null) return setLevel("part", deepestPart(i), { node: i });
927
+ if (u === i) return sysOf.has(i) ? setLevel("system", sysOf.get(i), { node: i }) : setLevel("module", moduleOf[i], { node: i });
928
+ return setLevel("unit", u, { node: i });
929
+ }
930
+ const top = () => (WS ? setLevel("ws", null) : setLevel("repo", repoIdx));
931
+ function render(refit) {
932
+ const r = S.level === "ws" || S.level === "view" ? null : S.level === "repo" ? S.focus : repoOf[S.focus];
933
+ $("commit").textContent = r != null && repoMeta(r).commit ? repoMeta(r).commit.slice(0, 7) : "";
934
+ crumbs(); tools(); draw(); side(); if (refit) requestAnimationFrame(fit);
935
+ }
936
+ function crumbs() {
937
+ if (S.level === "view") { $("crumbs").textContent = ""; return; }
938
+ const parts = WS ? [["Workspace", () => setLevel("ws", null)]] : [];
939
+ const r = S.level === "ws" ? null : S.level === "repo" ? S.focus : repoOf[S.focus];
940
+ if (r != null) parts.push([N[r].n, () => setLevel("repo", r)]);
941
+ if (S.level !== "repo" && S.level !== "ws") { const m = S.level === "module" ? S.focus : moduleOf[S.focus]; parts.push([N[m].n, () => setLevel("module", m)]); }
942
+ const pc = S.level === "part" ? S.focus : S.level === "unit" && PARTED.has(moduleOf[S.focus]) ? deepestPart(S.focus) : null;
943
+ if (pc != null) { const chain = []; for (let c = pc; c != null && N[c].k === "part"; c = N[c].p) chain.unshift(c); for (const c of chain) parts.push([N[c].n, () => setLevel("part", c)]); }
944
+ const sy = pc != null ? null : S.level === "system" ? S.focus : S.level === "unit" ? sysOf.get(S.focus) : null;
945
+ if (sy != null) parts.push([N[sy].n, () => setLevel("system", sy)]);
946
+ if (S.level === "unit") parts.push([N[S.focus].n, () => setLevel("unit", S.focus)]);
947
+ const c = $("crumbs"); c.textContent = "";
948
+ parts.forEach(([label, fn], j) => {
949
+ if (j) { const s = document.createElement("span"); s.className = "sep"; s.textContent = "/"; c.appendChild(s); }
950
+ const b = document.createElement("button"); b.textContent = label; b.onclick = fn;
951
+ if (j === parts.length - 1) b.setAttribute("aria-current", "page");
952
+ c.appendChild(b);
953
+ });
954
+ }
955
+ function tools() {
956
+ const t = $("tools"); t.textContent = "";
957
+ const chip = (label, on, fn, cls) => { const b = document.createElement("button"); b.className = "chip" + (cls ? " " + cls : ""); b.textContent = label; if (on != null) b.setAttribute("aria-pressed", on); b.onclick = fn; t.appendChild(b); };
958
+ const zoom = () => { chip("+", null, () => { const r = svg.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3); }, "zoom"); chip("−", null, () => { const r = svg.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3); }, "zoom"); chip("Fit", null, fit, "zoom"); };
959
+ if (S.mode === "flows") return;
960
+ if (S.mode === "change") {
961
+ const c = CHANGES[S.change];
962
+ if (c && c.after) {
963
+ const seg = document.createElement("div"); seg.className = "seg"; seg.setAttribute("role", "group"); seg.setAttribute("aria-label", "Show");
964
+ for (const [ph, label] of [["plan", "The plan"], ["after", "What was done"]]) { const b = document.createElement("button"); b.textContent = label; b.dataset.phase = ph; b.setAttribute("aria-pressed", S.phase === ph); b.onclick = () => setPhase(ph); seg.appendChild(b); }
965
+ t.appendChild(seg);
966
+ }
967
+ if (c) zoom();
968
+ return;
969
+ }
970
+ if (S.mode === "views") {
971
+ if (VIEWS.length) { const sel = document.createElement("select"); sel.id = "view-pick"; sel.setAttribute("aria-label", "Saved view"); const groups = [["Changes and views", (v) => v.kind !== "pattern"], ["Design patterns", (v) => v.kind === "pattern" && !v.pattern.tests], ["Design patterns in test code", (v) => v.kind === "pattern" && v.pattern.tests]]; for (const [label, want] of groups) { const ks = VIEWS.map((v, i) => i).filter((i) => want(VIEWS[i])); if (!ks.length) continue; const g = document.createElement("optgroup"); g.label = `${label} (${ks.length})`; ks.filter((i, n) => n < 300 || i === S.viewIdx).forEach((i) => { const v = VIEWS[i], o = document.createElement("option"); o.value = i; o.textContent = (v.kind === "change" ? "Change: " : v.kind === "custom" ? "View: " : "") + v.title; o.selected = i === S.viewIdx; g.appendChild(o); }); sel.appendChild(g); } sel.onchange = () => { S.viewIdx = +sel.value; S.sel = null; viewSetup(); render(true); viewKey(); }; t.appendChild(sel); }
972
+ chip("+", null, () => { const r = svg.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3); }, "zoom"); chip("−", null, () => { const r = svg.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3); }, "zoom"); chip("Fit", null, fit, "zoom");
973
+ return;
974
+ }
975
+ for (const [k, label] of [["calls", "Calls"], ["types", "Type use"], ["inherit", "Inheritance"], ["imports", "Imports"], ["channels", "Events and processes"]]) {
976
+ if (k === "channels" && S.mode === "matrix") continue;
977
+ if (k === "imports" && S.level !== "repo" && S.level !== "ws") continue;
978
+ const f = S.filters[S.level];
979
+ chip(label, f[k], () => { f[k] = !f[k]; S.sel = S.sel && S.sel.node != null ? S.sel : null; render(true); });
980
+ }
981
+ if (S.level === "repo" && WS) chip("Other repositories", S.outside.repo, () => { S.outside.repo = !S.outside.repo; render(true); });
982
+ if (S.level === "repo") chip("Outside packages", S.externals, () => { S.externals = !S.externals; render(true); });
983
+ else if (S.level !== "ws") chip(S.level === "module" ? "Other modules" : S.level === "system" ? "Neighbouring systems" : S.level === "part" ? "Neighbouring parts" : "Outside callers", S.outside[S.level], () => { S.outside[S.level] = !S.outside[S.level]; render(true); });
984
+ if (S.mode === "map") zoom();
985
+ }
986
+
987
+ /* ---------- inspector ---------- */
988
+ const LANG = { csharp: "C#", python: "Python", typescript: "TypeScript", javascript: "JavaScript", go: "Go", rust: "Rust", java: "Java", bash: "shell" };
989
+ const KIND = { part: "Part", system: "System", module: "Module", file: "File", type: "Type", callable: "Function", field: "Field", external: "Outside package", repo: "Repository" };
990
+ function kindName(i) { const n = N[i], x = n.x || {}; if (n.k === "test") return "test"; if (n.k === "type") return x.native_kind || "type"; if (n.k === "callable") return x.native_kind === "constructor" ? "constructor" : x.native_kind === "local_function" ? "local function" : x.native_kind || "function"; if (n.k === "field") return x.native_kind || "field"; return KIND[n.k] || n.k; }
991
+ function where(i) { const n = N[i]; if (!n.f && n.k !== "module") return ""; return n.k === "module" ? (n.f || ".") + "/" : n.a && n.k !== "file" ? `${n.f}:${n.a}–${n.b}` : n.f; }
992
+ function qual(i) { const n = N[i]; const u = unitOf[i]; if (isBody(i)) return N[fileOf[i]].n; if (n.k === "module" || u == null) return n.n; if (u === i) return n.n; const parts = []; for (let c = i; c != null && c !== N[u].p; c = N[c].p) parts.unshift(short(c)); return parts.join("."); }
993
+ function listHtml(title, rows, total, empty) {
994
+ if (!rows.length) return empty ? `<h3>${title}</h3><p class="note">${empty}</p>` : "";
995
+ // Rows that read the same (Builder.notes in each of four scripts) name their file, so they can be told apart.
996
+ const twice = new Map(); for (const r of rows) twice.set(r.name, (twice.get(r.name) || 0) + 1);
997
+ rows = rows.map((r) => twice.get(r.name) > 1 && r.go != null && N[r.go] && N[r.go].f && N[r.go].f !== r.name ? { ...r, name: `${r.name} (${N[r.go].f})` } : r);
998
+ const items = rows.map((r) => r.go != null
999
+ ? `<li><button data-go="${r.go}"${r.edge ? ` data-edge="${esc(r.edge)}"` : ""}><span class="nm">${esc(r.name)}${r.sub ? `<small>${esc(r.sub)}</small>` : ""}</span><span class="ct">${r.ct != null ? esc(r.ct) : ""}</span></button></li>`
1000
+ : `<li><div class="plain"><span class="nm">${esc(r.name)}${r.sub ? `<small>${esc(r.sub)}</small>` : ""}</span><span class="ct">${r.ct != null ? esc(r.ct) : ""}</span></div></li>`).join("");
1001
+ const more = total > rows.length ? `<div class="more">and ${total - rows.length} more</div>` : "";
1002
+ return `<h3>${title} <span>${total}</span></h3><ul class="list">${items}</ul>${more}`;
1003
+ }
1004
+ // Links touching a node (or anything inside it), grouped by the other end at a chosen grain.
1005
+ function around(i, dir, grain) {
1006
+ const insideI = N[i].k === "part" ? (j) => fileOf[j] != null && PARTS.get(i).has(fileOf[j]) : N[i].k === "system" ? (j) => unitOf[j] != null && sysOf.get(unitOf[j]) === i : (j) => { for (let c = j; c != null; c = N[c].p) if (c === i) return true; return false; };
1007
+ const acc = new Map();
1008
+ for (const Lk of LINKS) {
1009
+ if (GROUPS[Lk[0]] === "imports" && N[i].k !== "module" && N[i].k !== "file") continue;
1010
+ const me = dir === "out" ? Lk[1] : Lk[2], other = dir === "out" ? Lk[2] : Lk[1];
1011
+ if (!insideI(me) || insideI(other)) continue;
1012
+ const g = grain(other); if (g == null) continue;
1013
+ let r = acc.get(g); if (!r) acc.set(g, r = { go: g, n: 0, kinds: {}, guess: 0 });
1014
+ const kk = Lk[0] === "communicates" ? "communicates:" + Lk[6].channel : Lk[0];
1015
+ r.n += Lk[4]; r.kinds[kk] = (r.kinds[kk] || 0) + Lk[4]; if (Lk[3] === 0) r.guess += Lk[4];
1016
+ }
1017
+ const home = N[i].k === "module" ? i : moduleOf[i];
1018
+ return [...acc.values()].sort((a, b) => b.n - a.n).map((r) => {
1019
+ const ks = Object.entries(r.kinds), far = N[r.go].k !== "module" && N[r.go].k !== "external" && moduleOf[r.go] !== home;
1020
+ const lab = (k) => (k.startsWith("communicates:") ? (CHAN[k.slice(13)] ? CHAN[k.slice(13)][dir === "out" ? 1 : 2] : k.slice(13)) : LABEL[k]);
1021
+ const sub = [ks.length > 1 || ks[0][0] !== "calls" ? ks.map(([k, v]) => (k.startsWith("communicates:") ? lab(k) : `${lab(k)} ${v}`)).join(" · ") : "", far ? "in " + N[moduleOf[r.go]].n : "", r.guess ? (r.guess === r.n ? "guessed by name" : `${r.guess} guessed`) : ""].filter(Boolean).join(" · ");
1022
+ return { go: r.go, name: grainName(r.go, i), sub, ct: r.n };
1023
+ });
1024
+ }
1025
+ const grainName = (g, from) => { const n = N[g]; if (n.k === "system" || n.k === "part") return n.n; if (isBody(g)) return N[fileOf[g]].n + " (script body)"; if (n.k === "module" || n.k === "external" || unitOf[g] === g) return n.n; return (unitOf[g] !== unitOf[from] ? N[unitOf[g]].n + "." : "") + short(g); };
1026
+ const byModule = (j) => (N[j].k === "external" ? j : N[j].k === "module" ? j : moduleOf[j]);
1027
+ const byUnit = (j) => (N[j].k === "external" ? null : unitOf[j]);
1028
+ const bySystem = (home) => (j) => (N[j].k === "external" || unitOf[j] == null ? null : moduleOf[j] === home ? grp(unitOf[j]) : moduleOf[j]);
1029
+ // The other end of a part's links: a part of the same level (or of the level above), or another module.
1030
+ const byPart = (p) => { const lvl = N[N[p].p].k === "part" ? N[p].p : null; return (j) => (N[j].k === "external" || unitOf[j] == null ? null : moduleOf[j] === moduleOf[p] ? partFor(j, lvl) ?? unitOf[j] : moduleOf[j]); };
1031
+ const PART_KIND = { folder: "folder", files: "loose files of a folder", system: "group of files that work together", other: "files no group took", file: "file", more: "the rest of the level", module: "module" };
1032
+ function notesHtml(i) {
1033
+ const ns = NOTES[i]; if (!ns) return "";
1034
+ return Object.entries(ns).filter(([k]) => k !== "name").map(([k, a]) => `<p class="note"><span class="tag${a.layer === "inferred" ? " heur" : ""}">${a.layer === "inferred" ? "inferred" : "yours"}${a.c != null ? " · " + Math.round(a.c * 100) + "%" : ""}</span>${a.stale ? ` <span class="tag entry">stale: the code changed</span>` : ""} ${esc(a.v)}</p>`).join("");
1035
+ }
1036
+ const byMember = (j) => (N[j].k === "external" ? null : N[j].k === "callable" || N[j].k === "type" ? j : memberOf(j) ?? unitOf[j]);
1037
+
1038
+ function side() {
1039
+ const s = $("side"), sel = S.sel || {};
1040
+ let html = "";
1041
+ if (S.mode === "change") html = S.change != null && sel.edge ? backHtml() + edgeHtml(sel.edge) : S.change != null && sel.node != null ? backHtml() + viewNodeHtml(sel.node) : changeHtml();
1042
+ else if (S.mode === "views") html = sel.edge ? edgeHtml(sel.edge) : sel.node != null ? viewNodeHtml(sel.node) : viewHtml();
1043
+ else if (S.mode === "flows") html = sel.node != null ? nodeHtml(sel.node) : flowHtml();
1044
+ else if (sel.edge) html = edgeHtml(sel.edge);
1045
+ else if (sel.node != null) html = nodeHtml(sel.node);
1046
+ else if (S.level === "ws") html = wsHtml();
1047
+ else if (S.level === "repo") html = repoHtml(S.focus);
1048
+ else html = nodeHtml(S.focus, true);
1049
+ if (S.mode === "map" && S.change != null) html = shownHtml() + html;
1050
+ s.innerHTML = tourHtml() + html; s.scrollTop = 0;
1051
+ if (S.tour) {
1052
+ const move = (d) => { S.tour.stop = Math.max(0, Math.min(TOURS[S.tour.idx].stops.length - 1, S.tour.stop + d)); tourShow(); };
1053
+ if ($("tour-prev")) $("tour-prev").onclick = () => move(-1);
1054
+ if ($("tour-next")) $("tour-next").onclick = () => move(1);
1055
+ if ($("tour-back")) $("tour-back").onclick = () => tourShow();
1056
+ $("tour-end").onclick = () => { S.tour = null; $("tab-tour").setAttribute("aria-pressed", "false"); side(); };
1057
+ if ($("tour-pick")) $("tour-pick").onchange = (e) => { S.tour = { idx: +e.target.value, stop: 0 }; tourShow(); };
1058
+ }
1059
+ for (const b of s.querySelectorAll("[data-go]")) b.onclick = () => goTo(+b.dataset.go);
1060
+ for (const b of s.querySelectorAll("[data-view]")) b.onclick = () => { S.viewIdx = +b.dataset.view; S.sel = null; if (S.mode === "views") { viewSetup(); render(true); viewKey(); } else setMode("views"); };
1061
+ for (const b of s.querySelectorAll("[data-open]")) b.onclick = () => open(+b.dataset.open);
1062
+ for (const b of s.querySelectorAll("[data-flow]")) b.onclick = () => openFlow(+b.dataset.flow, b.dataset.at ? +b.dataset.at : null);
1063
+ for (const b of s.querySelectorAll("[data-edge]")) b.onclick = () => select({ edge: b.dataset.edge });
1064
+ wireChange(s);
1065
+ }
1066
+ function repoHtml(ri) {
1067
+ const r = repoMeta(ri), mine = (k) => repoOf[k] === ri, here = (kind) => N.filter((n, k) => counted(n, kind) && mine(k)).length;
1068
+ const mods = here("module");
1069
+ const facts = [[mods, "modules"], [here("file"), "files"], [big(linesOf(ri)), "lines"], [here("type"), "types"], [here("callable"), "functions"]];
1070
+ const entries = [...entryOf.entries()].filter(([t, e]) => (N[e].x || {}).trigger === "cli" && mine(t)).map(([t, e]) => ({ go: t, name: qual(t), sub: isBody(t) ? N[moduleOf[t]].n + " · script" : N[t].f, ct: "" }));
1071
+ const cov = D.coverage.filter((c) => !WS || c.repo === N[ri].i);
1072
+ const ok = cov.filter((c) => c.status === "ok"), na = cov.filter((c) => c.status !== "ok");
1073
+ const stat = ok.map((c) => `<li><div class="plain"><span class="nm">${esc(c.extractor.replace("communicates:", "channel: ").replace("flows:static", "flows (static)").replace("systems:louvain", "systems (clustering)"))}<small>${esc(describe(c))}</small></span><span class="status ok">ran</span></div></li>`).join("");
1074
+ const nas = na.map((c) => `<li><div class="plain"><span class="nm">${esc(c.extractor.replace("communicates:", "channel: "))}</span><span class="status na">not analyzed</span></div></li>`).join("");
1075
+ function describe(c) { const st = c.stats || {}; if (c.extractor.startsWith("tree-sitter")) { const tot = st.calls_total || 0; return `${st.files || 0} files · ${tot.toLocaleString()} call sites: ${(st.calls_resolved || 0).toLocaleString()} linked (${(st.calls_by_unique_name || 0).toLocaleString()} by guess), ${(st.calls_external || 0).toLocaleString()} to outside code, ${(st.calls_unresolved || 0).toLocaleString()} unresolved`; } if (c.extractor === "timing") return `indexed in ${st.total_seconds} s: ${(st.lines || 0).toLocaleString()} lines at ${(st.lines_per_second || 0).toLocaleString()} lines a second${st.compiler ? ` (compiler pass ${st.compiler} s)` : ""}`; if (c.extractor.startsWith("exact:")) return `a compiler confirmed ${(st.calls_confirmed || 0).toLocaleString()} call links, removed ${(st.calls_removed || 0).toLocaleString()} and added ${(st.calls_added || 0).toLocaleString()}; ${((st.field_links_confirmed || 0) + (st.field_links_added || 0)).toLocaleString()} field links are exact${st.files_with_binding_errors ? `; ${st.files_with_binding_errors} files did not bind cleanly (missing packages)` : ""}`; if (c.extractor === "coverage") return `${(st.functions_ran || 0).toLocaleString()} functions ran${st.per_test ? ` under ${st.tests} tests` : ""}`; if (c.extractor === "communicates:http") return `${st.routes || 0} routes, ${st.requests || 0} requests, ${st.links || 0} linked${st.ambiguous ? `, ${st.ambiguous} ambiguous` : ""}`; if (c.extractor === "communicates:file") return `${st.write_sites || 0} writes, ${st.read_sites || 0} reads, ${st.links || 0} linked by matching path fragments`; if (c.extractor === "communicates:event") return `${st.events_declared || 0} events declared, ${st.links || 0} raiser-to-handler links, ${st.subscriptions_to_outside_events || 0} subscriptions to outside events`; if (c.extractor === "communicates:process") return `${st.launch_sites || 0} launch sites, ${st.links || 0} linked to a program in this repository`; if (c.extractor === "communicates:di") return `${st.registrations || 0} registrations, ${st.registrations_linked || 0} linked to their implementation (${st.links || 0} links)${st.registrations_outside ? `, ${st.registrations_outside} of types outside` : ""}${st.dependencies ? `; ${st.dependencies_linked || 0} of ${st.dependencies} Depends() linked` : ""}`; if (c.extractor === "communicates:queue") return `${st.publish_sites || 0} publish sites, ${st.subscriptions || 0} subscriptions, ${st.links || 0} linked by topic or message type${st.linked_by_creation ? ` (${st.linked_by_creation} guessed from a message created, not seen sent)` : ""}`; if (c.extractor === "communicates:db") return `${st.write_sites || 0} writes, ${st.read_sites || 0} reads of ${st.tables || 0} tables, ${st.links || 0} writer-to-reader links${st.in_tests || st.in_migrations ? `; ${(st.in_tests || 0) + (st.in_migrations || 0)} sites in tests and migrations left out` : ""}`; if (c.extractor === "communicates:rpc") return `${st.services || 0} services, ${st.call_sites || 0} calls to them, ${st.links || 0} linked${st.calls_to_services_outside ? `, ${st.calls_to_services_outside} to services outside` : ""}`; if (c.extractor === "systems:louvain") return Object.entries(st).map(([m, v]) => `${m}: ${v.systems ? v.systems + " groups" : "no clear split"}`).join(" · ") || "no module is large enough to split"; if (c.extractor === "flows:static") return `${st.flows || 0} flows, ${(st.steps || 0).toLocaleString()} steps`; return ""; }
1076
+ return `<div class="eyebrow">Repository${WS ? ` <span class="tag">1 of ${REPOS.length} in this workspace</span>` : ""}</div><h2>${esc(N[ri].n)}</h2>
1077
+ <div class="where">${esc(r.folder || "")}${r.url ? `<br>remote: ${esc(r.url)}` : ""}</div>
1078
+ <div class="facts">${facts.map(([v, l]) => `<div><b>${v}</b><span>${l}</span></div>`).join("")}</div>
1079
+ <p class="note">Each box is a module. An arrow from A to B means A depends on B, and its number counts the links behind it. Select a box or an arrow for detail.${WS ? " Modules of other repositories that this one links to are drawn dashed, named with their repository, and their arrows are in the colour of the key below the map." : ""}</p>
1080
+ ${CHANGES.length && S.change == null ? `<p class="note">${CHANGES.length === 1 ? "One change is" : CHANGES.length + " changes are"} planned for this code. Open <b>Changes</b> to see on the map what ${CHANGES.length === 1 ? "it" : "each"} will write.</p>` : ""}
1081
+ ${(() => { const by = new Map(); for (const [k, p] of PATTERN_AT) if (!p.tests && mine(p.marks[0].i)) (by.get(p.pattern) || by.set(p.pattern, []).get(p.pattern)).push(k); return by.size ? `<h3>Design patterns <span>${[...by.values()].reduce((a, b) => a + b.length, 0)}</span></h3><ul class="list">${[...by.entries()].sort((a, b) => b[1].length - a[1].length).map(([name, ks]) => `<li><button data-view="${ks[0]}"><span class="nm">${esc(name)}<small>${esc(PATTERN_AT.get(ks[0]).about)}</small></span><span class="ct">${ks.length}</span></button></li>`).join("")}</ul>` : ""; })()}
1082
+ ${listHtml("State assigned from several places", (D.state || []).filter((f) => mine(f.i)).slice(0, 8).map((f) => ({ go: f.i, name: qual(f.i), sub: "assigned from " + f.from.slice(0, 4).join(", ") + (f.from.length > 4 ? " and " + (f.from.length - 4) + " more" : ""), ct: `${f.w} set · ${f.r} read` })), (D.state || []).filter((f) => mine(f.i)).length)}
1083
+ ${listHtml("Systems", N.map((m, k) => k).filter((k) => N[k].k === "system" && mine(k)).map((k) => ({ go: k, name: N[k].n, sub: "in " + N[N[k].p].n + (NOTES[k] && NOTES[k].responsibility ? " · " + NOTES[k].responsibility.v : ""), ct: (unitsOf.get(k) || []).length + " types" })), here("system"))}
1084
+ ${listHtml("Where execution starts", entries.slice(0, 12), entries.length)}
1085
+ <h3>What was analyzed</h3><ul class="list">${stat}${nas}</ul>
1086
+ <p class="note">A channel that was not analyzed may still exist in the code. Dependency injection, HTTP, queues, databases and shared files are not on this map yet.</p>`;
1087
+ }
1088
+ function nodeHtml(i, isFocus) {
1089
+ const n = N[i], x = n.x || {};
1090
+ const tags = [];
1091
+ if (x.visibility) tags.push(`<span class="tag">${esc(x.visibility)}</span>`);
1092
+ if (x.is_static && n.k !== "module") tags.push(`<span class="tag">static</span>`);
1093
+ if (x.is_abstract && x.native_kind !== "interface") tags.push(`<span class="tag">abstract</span>`);
1094
+ if (entryTargets.has(i)) tags.push(`<span class="tag entry">entry point: ${esc((N[entryOf.get(i)].x || {}).trigger || "")}</span>`);
1095
+ if (n.l) tags.push(`<span class="tag">${esc(LANG[n.l] || n.l)}</span>`);
1096
+ if (WS && repoOf[i] != null && n.k !== "repo") tags.push(`<span class="tag">${esc(N[repoOf[i]].n)}</span>`);
1097
+ let h = `<div class="eyebrow">${esc(kindName(i))} ${tags.join("")}</div><h2>${esc(n.k === "callable" || n.k === "field" ? qual(i) : n.n)}</h2>`;
1098
+ if (x.signature && n.k !== "file") h += `<div class="sig">${esc(x.signature)}</div>`;
1099
+ if (n.k === "field" && x.declared_type) h += `<div class="sig">${esc(x.declared_type)} ${esc(n.n)}</div>`;
1100
+ const w = where(i); if (w) h += `<div class="where">${esc(w)}${x.also_in ? " and " + x.also_in.map(esc).join(", ") : ""}</div>`;
1101
+ h += notesHtml(i);
1102
+ if (n.k === "type" || n.k === "callable") h += patternRows(patternsOf(i));
1103
+ if (n.k === "system") {
1104
+ const us = (unitsOf.get(i) || []).slice().sort((a, b) => count(b, "callable") - count(a, "callable")), x2 = n.x || {};
1105
+ const named = NOTES[i] && NOTES[i].name;
1106
+ h = `<div class="eyebrow">System <span class="tag heur">${named ? "grouped by clustering, named by inference" : "proposed by clustering"}</span></div><h2>${esc(n.n)}</h2><div class="where">in ${esc(N[n.p].n)}</div>` + notesHtml(i);
1107
+ h += `<div class="facts"><div><b>${us.length}</b><span>types and files</span></div><div><b>${us.reduce((t, u) => t + count(u, "callable"), 0)}</b><span>functions</span></div><div><b>${x2.cohesion != null ? Math.round(x2.cohesion * 100) + "%" : "–"}</b><span>of its links stay inside</span></div></div>`;
1108
+ if (!isFocus) h += `<button class="open" data-open="${i}">Open system</button>`;
1109
+ if (!named) h += `<p class="note">This group has no real name yet. It is called after its most connected type.</p>`;
1110
+ const dep = around(i, "out", bySystem(n.p)), usedBy = around(i, "in", bySystem(n.p));
1111
+ h += listHtml("Members", us.slice(0, 40).map((u) => ({ go: u, name: N[u].n, ct: count(u, "callable") + " fn" })), us.length);
1112
+ h += listHtml("Depends on", dep.slice(0, 12), dep.length) + listHtml("Used by", usedBy.slice(0, 12), usedBy.length);
1113
+ return h;
1114
+ }
1115
+ if (n.k === "part") {
1116
+ const subs = subsOf(i), fs = [...(PARTS.get(i) || [])].sort((a, b) => (N[b].b || 0) - (N[a].b || 0));
1117
+ h = `<div class="eyebrow">Part <span class="tag heur">${esc(PART_KIND[x.kind] || x.kind || "part")}</span>${x.stale ? ` <span class="tag entry">its name may be stale</span>` : ""}</div><h2>${esc(n.n)}</h2><div class="where">${esc((x.label && x.label !== n.n ? x.label + " · " : "") + "in " + N[moduleOf[i]].n)}</div>`;
1118
+ if (x.summary) h += `<p class="note">${esc(x.summary)}</p>`;
1119
+ h += `<div class="facts"><div><b>${x.files || 0}</b><span>files</span></div><div><b>${(x.functions || 0).toLocaleString()}</b><span>functions</span></div><div><b>${big(x.lines || 0)}</b><span>lines</span></div></div>`;
1120
+ if (!isFocus) h += `<button class="open" data-open="${i}">Open part</button>`;
1121
+ if (!x.named) h += `<p class="note">This part has no name yet: it is called after its folder or its most linked file. An agent names parts after reading them (the leyline-explore-module skill, or <code>leyline name-part</code>).</p>`;
1122
+ if (x.stale) h += `<p class="note">Its files have changed a lot since it was named (${esc(x.stale.replace(/^may be stale: /, ""))}).</p>`;
1123
+ const dep = around(i, "out", byPart(i)), usedBy = around(i, "in", byPart(i));
1124
+ h += listHtml("Parts", subs.map((c) => ({ go: c, name: N[c].n, sub: (N[c].x || {}).summary || "", ct: ((N[c].x || {}).files || 0) + " files" })), subs.length);
1125
+ h += listHtml("Depends on", dep.slice(0, 12), dep.length) + listHtml("Used by", usedBy.slice(0, 12), usedBy.length);
1126
+ h += listHtml("Files", fs.slice(0, 40).map((f) => ({ go: f, name: N[f].f || N[f].n, ct: (N[f].b || 0) + " lines" })), fs.length);
1127
+ return h;
1128
+ }
1129
+ if (n.k === "external") return h + `<p class="note">Code from outside this repository${x.version ? `, version ${esc(x.version)}` : ""}.</p>` + listHtml("Used by", around(i, "in", byModule), around(i, "in", byModule).length);
1130
+ if (n.k === "module") {
1131
+ const types = []; const st = [...kids[i]]; while (st.length) { const c = st.pop(); if (N[c].k === "type" && unitOf[c] === c) types.push(c); else if (N[c].k === "file") st.push(...kids[c]); }
1132
+ const pub = types.filter((t) => /public/.test((N[t].x || {}).visibility || "")).sort((a, b) => N[a].n.localeCompare(N[b].n));
1133
+ const files = kids[i].filter((c) => N[c].k === "file");
1134
+ h += `<div class="facts"><div><b>${files.length}</b><span>files</span></div><div><b>${linesOf(i).toLocaleString()}</b><span>lines</span></div><div><b>${types.length}</b><span>types</span></div><div><b>${count(i, "callable")}</b><span>functions</span></div></div>`;
1135
+ if (!isFocus) h += `<button class="open" data-open="${i}">Open module</button>`;
1136
+ const dep = around(i, "out", byModule), usedBy = around(i, "in", byModule);
1137
+ h += listHtml("Depends on", dep.slice(0, 12), dep.length, "Nothing in this repository.");
1138
+ h += listHtml("Used by", usedBy.slice(0, 12), usedBy.length, "Nothing in this repository.");
1139
+ const eps = [...entryTargets].filter((t) => moduleOf[t] === i).map((t) => ({ go: t, name: qual(t), sub: N[t].f, ct: (N[entryOf.get(t)].x || {}).trigger }));
1140
+ const syss = N.map((m, k) => k).filter((k) => N[k].k === "system" && N[k].p === i);
1141
+ if (PARTED.has(i)) h += listHtml("Parts", subsOf(i).map((c) => ({ go: c, name: N[c].n, sub: (N[c].x || {}).summary || (N[c].x || {}).label || "", ct: ((N[c].x || {}).files || 0) + " files" })), subsOf(i).length);
1142
+ h += listHtml("Systems", syss.map((k) => ({ go: k, name: N[k].n, sub: NOTES[k] && NOTES[k].responsibility ? NOTES[k].responsibility.v : "", ct: (unitsOf.get(k) || []).length + " types" })), syss.length);
1143
+ h += listHtml("Entry points", eps.slice(0, 10), eps.length);
1144
+ h += listHtml("Public types", pub.slice(0, 40).map((t) => ({ go: t, name: N[t].n, ct: (N[t].x || {}).native_kind })), pub.length);
1145
+ if (types.length > pub.length) h += `<p class="note">${types.length - pub.length} more types are internal to this module.</p>`;
1146
+ h += listHtml("Files", files.slice(0, 40).map((f) => ({ go: f, name: N[f].n, ct: (N[f].b || 0) + " lines" })), files.length);
1147
+ return h;
1148
+ }
1149
+ const isUnit = unitOf[i] === i;
1150
+ if (isUnit || n.k === "type") {
1151
+ const fns = kids[i].filter((c) => N[c].k === "callable"), fields = kids[i].filter((c) => N[c].k === "field"), nested = kids[i].filter((c) => N[c].k === "type");
1152
+ if (isUnit && !isFocus) h += `<button class="open" data-open="${i}">Open ${n.k === "file" ? "file" : "type"}</button>`;
1153
+ if (sysOf.has(i)) h += listHtml("Part of", [{ go: sysOf.get(i), name: N[sysOf.get(i)].n, ct: "system" }], 1);
1154
+ else if (PARTED.has(moduleOf[i]) && deepestPart(i) != null) h += listHtml("Part of", [{ go: deepestPart(i), name: N[deepestPart(i)].n, ct: "part" }], 1);
1155
+ const bases = LINKS.filter((l) => l[1] === i && GROUPS[l[0]] === "inherit").map((l) => ({ go: l[2], name: N[l[2]].n, ct: LABEL[l[0]] }));
1156
+ const subs = LINKS.filter((l) => l[2] === i && GROUPS[l[0]] === "inherit").map((l) => ({ go: l[1], name: N[l[1]].n, ct: l[0] === "implements" ? "implements this" : "extends this" }));
1157
+ h += listHtml("Built on", bases, bases.length) + listHtml("Built on by", subs.slice(0, 20), subs.length);
1158
+ const vis = (c) => /public/.test((N[c].x || {}).visibility || "") ? 0 : 1;
1159
+ fns.sort((a, b) => vis(a) - vis(b) || (N[a].a || 0) - (N[b].a || 0));
1160
+ h += listHtml("Functions", fns.slice(0, 60).map((c) => ({ go: c, name: short(c), sub: (N[c].x || {}).signature, ct: (N[c].x || {}).visibility === "public" ? "" : ((N[c].x || {}).visibility || "") })), fns.length);
1161
+ h += listHtml("Fields", fields.slice(0, 40).map((c) => ({ go: c, name: c && N[c].n, ct: (N[c].x || {}).declared_type || "" })), fields.length);
1162
+ h += accessHtml(i);
1163
+ h += listHtml("Nested types", nested.map((c) => ({ go: c, name: N[c].n, ct: (N[c].x || {}).native_kind })), nested.length);
1164
+ const dep = around(i, "out", byUnit), usedBy = around(i, "in", byUnit);
1165
+ h += listHtml("Uses", dep.slice(0, 15), dep.length) + listHtml("Used by", usedBy.slice(0, 15), usedBy.length, "Nothing found. It may be reached through an entry point or a channel that was not analyzed.");
1166
+ return h;
1167
+ }
1168
+ if (n.k === "test") {
1169
+ const fi = FLOWS.findIndex((f) => f.entry === i);
1170
+ if (fi >= 0 && S.mode !== "flows") h += `<button class="open" data-flow="${fi}">Trace this test</button>`;
1171
+ const callees = around(i, "out", byMember).filter((r) => N[r.go].k === "callable");
1172
+ h += listHtml("Calls", callees.slice(0, 40), callees.length);
1173
+ h += accessHtml(i);
1174
+ }
1175
+ if (n.k === "field") h += accessHtml(i);
1176
+ if (n.k === "callable") {
1177
+ const callees = around(i, "out", byMember).filter((r) => N[r.go].k === "callable"), callers = around(i, "in", byMember).filter((r) => N[r.go].k === "callable");
1178
+ const types = around(i, "out", byUnit).filter((r) => !callees.some((c) => unitOf[c.go] === r.go) );
1179
+ const bases = basesOf.get(i) || [], impls = implsOf.get(i) || [];
1180
+ const callersOf = (j) => LINKS.filter((l) => l[2] === j && l[0] === "calls").reduce((a, l) => a + l[4], 0);
1181
+ h += listHtml("Calls", callees.slice(0, 30), callees.length, impls.length ? "" : "No calls into this repository were found.");
1182
+ h += listHtml("Implements", bases.map((b) => ({ go: b, name: N[unitOf[b]].n + "." + short(b), sub: `${callersOf(b)} call sites go through it`, ct: "" })), bases.length);
1183
+ h += listHtml("Implemented by", impls.slice(0, 20).map((m) => ({ go: m, name: N[unitOf[m]].n + "." + short(m), sub: moduleOf[m] !== moduleOf[i] ? "in " + N[moduleOf[m]].n : "", ct: "" })), impls.length);
1184
+ h += listHtml("Called by", callers.slice(0, 30), callers.length, entryTargets.has(i) ? "Nothing calls it: execution starts here." : bases.length ? `No direct callers. Callers reach it through ${esc(N[unitOf[bases[0]]].n)}.${esc(short(bases[0]))}, listed above.` : "No callers found. It may be called through an event, a delegate or an outside framework.");
1185
+ h += measuredHtml(i);
1186
+ h += accessHtml(i);
1187
+ h += listHtml("Other types it touches", types.slice(0, 12), types.length);
1188
+ const fl = flowsOf.get(i) || [], tests = fl.filter((fi) => FLOWS[fi].kind === "test");
1189
+ if (fl.length) h += `<h3>Flows through here <span>${fl.length} of ${FLOWS.length}</span></h3><ul class="list">${fl.slice(0, 12).map((fi) => `<li><button data-flow="${fi}" data-at="${i}"><span class="nm">${esc(flowName(FLOWS[fi]))}<small>${flowGroup(FLOWS[fi]).replace(/s$/, "").toLowerCase()}</small></span><span class="ct">step ${FLOWS[fi].steps.findIndex((s) => s[0] === i) + 1}</span></button></li>`).join("")}</ul>${fl.length > 12 ? `<div class="more">and ${fl.length - 12} more</div>` : ""}`;
1190
+ else if (FLOWS.length) h += `<h3>Flows through here</h3><p class="note">None. No entry point or test reaches it through calls the indexer could resolve.</p>`;
1191
+ if (fl.length && !tests.length) h += `<p class="note">No test reaches this function.</p>`;
1192
+ }
1193
+ const src = n.f ? D.sources[srcKey(i)] : null;
1194
+ if (src && n.a) {
1195
+ let lines = src.split(/\r?\n/).slice(n.a - 1, Math.min(n.b, n.a + 399)).map((ln) => ln.replace(/\t/g, " "));
1196
+ const indent = Math.min(...lines.filter((ln) => ln.trim()).map((ln) => ln.length - ln.trimStart().length));
1197
+ if (indent > 0 && isFinite(indent)) lines = lines.map((ln) => ln.slice(indent));
1198
+ h += `<h3>Source <span>${n.b - n.a + 1} lines</span></h3><pre class="code"><code>${lines.map((ln, k) => `<span class="ln">${n.a + k}</span>${esc(ln)}`).join("\n")}</code></pre>`;
1199
+ if (n.b - n.a + 1 > 400) h += `<div class="more">First 400 lines shown.</div>`;
1200
+ }
1201
+ return h;
1202
+ }
1203
+ function edgeHtml(key) {
1204
+ const e = S.view.edges.find((x) => x.key === key);
1205
+ if (!e) return S.level === "ws" ? wsHtml() : repoHtml(S.focus != null && N[S.focus].k === "repo" ? S.focus : repoOf[S.focus] ?? repoIdx);
1206
+ const kinds = Object.entries(e.kinds).sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, ct: v }));
1207
+ const pairs = new Map();
1208
+ for (const Lk of e.links) { const k = Lk[1] + ">" + Lk[2] + ">" + Lk[0]; const p = pairs.get(k); if (p) p.n += Lk[4]; else pairs.set(k, { s: Lk[1], d: Lk[2], k: linkLabel(Lk), n: Lk[4], g: Lk[3] === 0, x: Lk[6] }); }
1209
+ const rows = [...pairs.values()].sort((a, b) => b.n - a.n).map((p) => ({ go: p.s, name: `${qual(p.s)} → ${qual(p.d)}`, sub: [p.x ? "" : p.k, p.x && p.x.channel === "event" ? "event " + String(p.x.address).split(".").pop() : "", p.x && p.x.channel === "process" ? `the command mentions “${p.x.address}”${p.x.pipes ? " and wires up stdin and stdout" : ""}` : "", p.x && p.x.channel !== "event" && p.x.channel !== "process" && p.x.address ? `${p.x.channel} ${p.x.address}${p.x.created_only ? " (created, not seen sent)" : ""}` : "", p.g ? "guessed by name" : "", N[p.s].f || ""].filter(Boolean).join(" · "), ct: p.n > 1 ? "×" + p.n : "" }));
1210
+ const heur = e.n - e.exact;
1211
+ if (e.comm) return `<div class="eyebrow">Channel <span class="tag chan">${esc([...e.chans].join(", "))}</span><span class="tag heur">${e.guess ? "partly guessed" : "from syntax"}</span></div>
1212
+ <h2>${esc(N[e.a].n)} → ${esc(N[e.b].n)}</h2>
1213
+ <p class="note">${e.chans.has("process") ? `Code in ${esc(N[e.a].n)} starts ${esc(N[e.b].n)} as a separate program. No code reference connects them, so a compiler would not report this link.` : e.chans.has("event") ? `Code in ${esc(N[e.a].n)} raises an event that code in ${esc(N[e.b].n)} handles. ${esc(N[e.a].n)} does not depend on ${esc(N[e.b].n)}: the handler subscribes at run time.` : `Code in ${esc(N[e.a].n)} ${esc((CHAN[[...e.chans][0]] || ["reaches"])[0])} ${esc(N[e.b].n)}. The two ends meet by a name (${esc([...e.chans].join(", "))}), not a code reference, so a compiler would not report this link.`}</p>
1214
+ ${listHtml("Links", rows.slice(0, 60), rows.length)}`;
1215
+ return `<div class="eyebrow">Dependency ${heur ? `<span class="tag heur">${heur === e.n ? "from syntax" : "partly from syntax"}</span>` : `<span class="tag">exact</span>`}</div>
1216
+ <h2>${esc(N[e.a].n)} → ${esc(N[e.b].n)}</h2>
1217
+ <p class="note">${esc(N[e.a].n)} depends on ${esc(N[e.b].n)} through ${e.n.toLocaleString()} link${e.n === 1 ? "" : "s"}.${heur ? ` ${heur.toLocaleString()} of them were worked out from syntax alone and can be wrong.` : ""}${e.guess ? ` ${e.guess.toLocaleString()} of those are guesses: the target is the only function with that name.` : ""}</p>
1218
+ ${listHtml("What crosses", kinds, kinds.length)}
1219
+ ${listHtml("Links", rows.slice(0, 60), rows.length)}`;
1220
+ }
1221
+
1222
+ /* ---------- matrix ---------- */
1223
+ function drawMatrix() {
1224
+ const m = $("matrix");
1225
+ const ids = [...L.boxes.values()].filter((b) => !b.stub && !b.dummy).sort((a, b) => b.y - a.y || a.x - b.x).map((b) => b.i);
1226
+ const at = new Map(ids.map((i, j) => [i, j]));
1227
+ const cell = new Map(S.view.edges.filter((e) => !e.comm && at.has(e.a) && at.has(e.b)).map((e) => [e.a + ">" + e.b, e]));
1228
+ let backs = 0;
1229
+ let h = `<table class="matrix"><thead><tr><th></th>${ids.map((i) => `<th class="col"><div>${esc(short(i))}</div></th>`).join("")}</tr></thead><tbody>`;
1230
+ ids.forEach((r, ri) => {
1231
+ h += `<tr><th class="row" title="${esc(N[r].n)}">${esc(short(r))}</th>`;
1232
+ ids.forEach((c, ci) => {
1233
+ if (r === c) { h += `<td class="diag"></td>`; return; }
1234
+ const e = cell.get(r + ">" + c);
1235
+ if (!e) { h += "<td></td>"; return; }
1236
+ const back = ci > ri; if (back) backs++;
1237
+ h += `<td class="on${back ? " back" : ""}" data-key="${e.key}" title="${esc(N[r].n)} depends on ${esc(N[c].n)}: ${e.n}">${e.n > 999 ? "1k+" : e.n}</td>`;
1238
+ });
1239
+ h += "</tr>";
1240
+ });
1241
+ h += "</tbody></table>";
1242
+ const note = `<p class="matrix-note">Each row depends on the columns it has numbers in. Rows are ordered so that dependencies sit below the diagonal. ${backs ? `${backs} cell${backs === 1 ? "" : "s"} above it, tinted, point back up the order: those are cycles.` : "Nothing sits above it, so there are no cycles at this level."} Channels that are not calls (events, launches, messages, tables…) are left out, because they are not compile-time dependencies.</p>`;
1243
+ m.innerHTML = note + h;
1244
+ for (const td of m.querySelectorAll("td.on")) td.onclick = () => select({ edge: td.dataset.key });
1245
+ mark();
1246
+ }
1247
+ /* ---------- flows ---------- */
1248
+ const flowGroup = (f) => (f.kind === "test" ? "Tests" : f.detail === "lifecycle" ? "Framework callbacks" : "Program starts");
1249
+ const flowName = (f) => (f.kind === "test" ? f.name : qual(f.entry) + (isBody(f.entry) ? "" : " · " + N[moduleOf[f.entry]].n));
1250
+ function drawFlow() {
1251
+ const v = $("flowview");
1252
+ if (!FLOWS.length) { v.innerHTML = `<p class="note">No flows were found. A flow needs an entry point or a test to start from.</p>`; return; }
1253
+ const f = FLOWS[S.flow];
1254
+ const groups = {};
1255
+ FLOWS.forEach((x, i) => (groups[flowGroup(x)] = groups[flowGroup(x)] || []).push(i));
1256
+ // With thousands of flows a plain list is useless: filter by words in the flow's name or module.
1257
+ const words = (S.flowFilter || "").toLowerCase().split(/\s+/).filter(Boolean);
1258
+ const hay = (x) => (flowName(x) + " " + N[moduleOf[x.entry]].n + " " + (N[x.entry].f || "")).toLowerCase();
1259
+ const CAP = 400; let shown = 0, matched = 0;
1260
+ const optsFor = () => ["Program starts", "Tests", "Framework callbacks"].filter((g) => groups[g]).map((g) => {
1261
+ const hit = groups[g].filter((i) => i === S.flow || !words.length || words.every((w) => hay(FLOWS[i]).includes(w)));
1262
+ matched += hit.length;
1263
+ const list = hit.slice(0, Math.max(0, CAP - shown)); if (hit.includes(S.flow) && !list.includes(S.flow)) list.push(S.flow); shown += list.length;
1264
+ return list.length ? `<optgroup label="${g} (${hit.length})">${list.map((i) => `<option value="${i}"${i === S.flow ? " selected" : ""}>${esc(flowName(FLOWS[i]))} (${FLOWS[i].steps.length})</option>`).join("")}</optgroup>` : "";
1265
+ }).join("");
1266
+ const opts = optsFor();
1267
+ const mods = [...new Set(f.steps.map((s) => moduleOf[s[0]]))];
1268
+ const ranHere = f.kind === "test" ? RAN_IN.get(f.entry) : null; // what this test ran when coverage was measured
1269
+ let prev = null;
1270
+ const rows = f.steps.map((s, k) => {
1271
+ const [i, d, via] = s, m = moduleOf[i], showMod = m !== prev; prev = m;
1272
+ const cur = S.sel && S.sel.node === i;
1273
+ const idle = ranHere && k > 0 && WATCHED.has(fileNodeOf(i)) && !ranHere.has(i);
1274
+ return `<li><button data-step="${i}" style="--d:${d}"${cur ? ` aria-current="true"` : ""}${idle ? ` class="idle"` : ""}><span class="seq">${k + 1}</span><span class="ind"></span><span class="dot lang-dot" style="background:var(--${langOf(i) === "python" ? "py" : langOf(i) === "csharp" ? "cs" : "edge"})"></span><span>${esc(N[i].k === "test" ? N[i].n : qual(i))}</span>${CHAN[via] || via === "dispatch" || via === "fixture" ? `<span class="via">${CHAN[via] ? CHAN[via][3] : via === "fixture" ? "test fixture" : "one implementation"}</span>` : ""}${idle ? `<span class="via">did not run</span>` : ""}${showMod ? `<span class="mod">${esc(N[m].n)}</span>` : ""}</button></li>`;
1275
+ }).join("");
1276
+ v.innerHTML = `<div class="flow-head">${FLOWS.length > 30 ? `<input id="flow-find" type="search" placeholder="Filter ${FLOWS.length.toLocaleString()} flows" aria-label="Filter flows" value="${esc(S.flowFilter || "")}">` : ""}<select id="flow-pick" aria-label="Flow">${opts}</select>${matched > shown ? `<span>first ${shown} of ${matched.toLocaleString()} listed; filter to narrow</span>` : ""}<span>${f.steps.length} steps across ${mods.length} module${mods.length === 1 ? "" : "s"}${f.truncated ? ", cut short" : ""}</span></div><ol class="steps" id="steps">${rows}</ol>`;
1277
+ $("flow-pick").onchange = (e) => { S.flow = +e.target.value; S.sel = null; drawFlow(); side(); v.scrollTop = 0; };
1278
+ if ($("flow-find")) $("flow-find").oninput = (e) => { S.flowFilter = e.target.value; const at = e.target.selectionStart; drawFlow(); const box = $("flow-find"); box.focus(); box.setSelectionRange(at, at); };
1279
+ for (const b of v.querySelectorAll("[data-step]")) b.onclick = () => { S.sel = { node: +b.dataset.step }; for (const o of v.querySelectorAll("[aria-current]")) o.removeAttribute("aria-current"); b.setAttribute("aria-current", "true"); side(); };
1280
+ $("steps").onkeydown = (e) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; const bs = [...v.querySelectorAll("[data-step]")], at = bs.indexOf(document.activeElement); const n = bs[at + (e.key === "ArrowDown" ? 1 : -1)]; if (n) { e.preventDefault(); n.focus(); n.click(); } };
1281
+ }
1282
+ function flowHtml() {
1283
+ const f = FLOWS[S.flow]; if (!f) return "";
1284
+ const mods = [...new Set(f.steps.map((s) => moduleOf[s[0]]))].map((m) => ({ go: m, name: N[m].n, ct: f.steps.filter((s) => moduleOf[s[0]] === m).length + " steps" }));
1285
+ return `<div class="eyebrow">Flow <span class="tag">${f.kind === "test" ? "test" : esc(f.detail || "entry")}</span><span class="tag heur">static</span></div><h2>${esc(f.kind === "test" ? f.name : qual(f.entry))}</h2>
1286
+ <div class="where">${esc(where(f.entry))}</div>
1287
+ <p class="note">Everything this ${f.kind === "test" ? "test" : "entry point"} can reach, in the order the code is written. Each function appears once, at its first call. Indentation is call depth. Select a step to read its source here.</p>
1288
+ ${f.truncated ? `<p class="note">The walk stopped at ${f.steps.length} steps or 8 calls deep, so the deepest parts are missing.</p>` : ""}
1289
+ <p class="note">This is what can run, not what did run. Branches not taken at run time are still listed, and calls the indexer could not resolve are missing.</p>
1290
+ ${listHtml("Modules it passes through", mods, mods.length)}${flowMeasuredHtml(f)}`;
1291
+ }
1292
+ function flowMeasuredHtml(f) {
1293
+ const ran = f.kind === "test" ? RAN_IN.get(f.entry) : null;
1294
+ if (!ran) return MEAS.any.length && f.kind === "test" ? `<h3>Measured</h3><p class="note">The coverage file has nothing for this test.</p>` : "";
1295
+ const on = new Set(f.steps.map((s) => s[0]));
1296
+ const watched = f.steps.filter((s, k) => k > 0 && WATCHED.has(fileNodeOf(s[0])));
1297
+ const idle = watched.filter((s) => !ran.has(s[0]));
1298
+ const extra = [...ran].filter((i) => !on.has(i) && !isBody(i));
1299
+ return `<h3>Measured</h3><p class="note">When coverage was measured, ${watched.length - idle.length} of the ${watched.length} steps in watched files ran. Steps that did not run are dimmed: the test can reach them on paper and took another branch.</p>`
1300
+ + listHtml("Ran, but not on this path", extra.slice(0, 40).map((i) => ({ go: i, name: qual(i), sub: N[moduleOf[i]] ? "in " + N[moduleOf[i]].n : "", ct: "" })), extra.length)
1301
+ + (extra.length ? `<p class="note">These ran through a link the map does not have, such as a framework calling back into the code.</p>` : "");
1302
+ }
1303
+ /* ---------- saved views ---------- */
1304
+ function viewKey() {
1305
+ const k = $("viewkey"); if (!VW) return;
1306
+ const lg = (VIEWS[S.viewIdx] || {}).legend || {};
1307
+ k.innerHTML = VW.roles.map((r) => `<i class="key"><b class="k-${VW.cls(r)}"></b>${esc(lg[r] || ROLE_LABEL[r] || r)}</i>`).join("") + `<i>Double-click a box to open it on the map.</i>`;
1308
+ }
1309
+ function viewHtml() {
1310
+ const v = VIEWS[S.viewIdx];
1311
+ if (!v) return `<div class="eyebrow">Views</div><h2>No saved views</h2><p class="note">A view is a set of nodes an agent marked for you: the reach of a proposed change, or any other slice of the code. Ask an agent connected to Leyline to assess a change, then re-open or refresh this page.</p>`;
1312
+ const lvl = (r) => `<span class="tag ${r.level === "high" ? "risk-high" : r.level === "medium" ? "risk-medium" : ""}">${r.level} risk</span>`;
1313
+ if (v.kind === "review" && v.review) return reviewHtml(v);
1314
+ if (v.kind === "pattern") {
1315
+ const p = v.pattern;
1316
+ let h = `<div class="eyebrow">Design pattern <span class="tag heur">${esc(p.source === "structural matcher" ? "found by its shape" : "labelled by " + p.source)}</span>${p.stale ? `<span class="tag risk-medium">code changed since</span>` : ""}</div><h2>${esc(v.title)}</h2><p class="note">${esc(p.about)}</p><p class="note">${esc(p.rationale)}</p>`;
1317
+ h += `<div class="facts"><div><b>${Math.round(p.c * 100)}%</b><span>confidence in the shape</span></div><div><b>${v.marks.length}</b><span>nodes involved</span></div></div>`;
1318
+ for (const r of VW.roles) { const ms = v.marks.filter((m) => m.role === r); h += listHtml(esc(r), ms.slice(0, 40).map((m) => ({ go: m.i, name: N[m.i].k === "callable" || N[m.i].k === "field" ? qual(m.i) : N[m.i].n, sub: N[moduleOf[m.i]] ? "in " + N[moduleOf[m.i]].n : "", ct: "" })), ms.length); }
1319
+ h += `<p class="note">The label says the code has this shape. It does not say the author meant the pattern, and a pattern built another way is not found.</p>`;
1320
+ return h;
1321
+ }
1322
+ let h = `<div class="eyebrow">${v.kind === "change" ? "Proposed change" : "Saved view"} <span class="tag heur">${v.kind === "change" ? "static assessment" : "authored by an agent"}</span></div><h2>${esc(v.title)}</h2><p class="note">${esc(v.narrative || "")}</p>`;
1323
+ if (v.summary) { const s = v.summary; h += `<div class="facts"><div><b>${s.changed + s.added}</b><span>changed</span></div><div><b>${s.must_edit}</b><span>must edit</span></div><div><b>${s.reached}</b><span>reached</span></div><div><b>${s.tests_to_run}</b><span>tests to run</span></div></div>`; }
1324
+ if (v.risks && v.risks.length) h += `<h3>Risks</h3>` + v.risks.map((r) => `<p class="note">${lvl(r)} ${esc(r.what)}</p>`).join("");
1325
+ else if (v.kind === "change") h += `<h3>Risks</h3><p class="note">None flagged by the static checks.</p>`;
1326
+ if (v.new_nodes && v.new_nodes.length) h += listHtml("To be added", v.new_nodes.map((n) => ({ go: n.pi, name: n.name, sub: `in ${n.pi != null ? N[n.pi].n : "?"}${n.note ? " · " + n.note : ""}`, ct: "new" })), v.new_nodes.length);
1327
+ const byRole = (r) => v.marks.filter((m) => m.role === r);
1328
+ const row = (m) => ({ go: m.i, name: N[m.i].k === "callable" || N[m.i].k === "field" ? qual(m.i) : N[m.i].n, sub: [m.note, N[moduleOf[m.i]] ? "in " + N[moduleOf[m.i]].n : ""].filter(Boolean).join(" · "), ct: "" });
1329
+ if (v.kind === "change") {
1330
+ h += listHtml("Changed", byRole("changed").map(row), byRole("changed").length);
1331
+ const must = [...byRole("contract"), ...byRole("must_edit")];
1332
+ h += listHtml("Must be edited with it", must.slice(0, 40).map(row), must.length);
1333
+ if (v.channels && v.channels.length) h += listHtml("Reached through a channel", v.channels.map((c) => ({ go: c.fi, name: `${c.from_name} → ${c.to_name}`, sub: c.channel + (c.guessed ? " · guessed" : ""), ct: "" })), v.channels.length);
1334
+ h += listHtml("Tests to run", (v.tests_to_run || []).slice(0, 30).map((t) => ({ go: t.i, name: t.name, ct: t.touched_steps + " steps" })), (v.tests_to_run || []).length, "No test passes through the change.");
1335
+ h += listHtml("Changed code no test reaches", (v.untested || []).map((t) => ({ go: t.i, name: t.name, ct: "" })), (v.untested || []).length);
1336
+ h += listHtml("Entry points affected", (v.entry_points_affected || []).slice(0, 15).map((t) => ({ go: t.i, name: t.name, ct: t.trigger || "" })), (v.entry_points_affected || []).length);
1337
+ h += listHtml("By system", (v.by_system || []).map((g) => ({ go: g.i, name: g.system, sub: [g.changed ? g.changed + " changed" : "", g.must_edit ? g.must_edit + " to edit" : "", g.reached ? g.reached + " reached" : ""].filter(Boolean).join(" · "), ct: "" })), (v.by_system || []).length);
1338
+ h += listHtml("By module", (v.by_module || []).map((g) => ({ go: g.i, name: g.module, sub: [g.changed ? g.changed + " changed" : "", g.must_edit ? g.must_edit + " to edit" : "", g.reached ? g.reached + " reached" : ""].filter(Boolean).join(" · "), ct: "" })), (v.by_module || []).length);
1339
+ const rest = [...byRole("direct"), ...byRole("indirect")];
1340
+ h += listHtml("Reached, no edit needed", rest.slice(0, 30).map(row), rest.length);
1341
+ if (v.limits) h += `<p class="note">${esc(v.limits)}</p>`;
1342
+ } else {
1343
+ for (const r of VW.roles) h += listHtml(esc((v.legend && v.legend[r]) || ROLE_LABEL[r] || r), byRole(r).slice(0, 40).map(row), byRole(r).length);
1344
+ }
1345
+ if (VW.trimmed) h += `<p class="note">The map leaves out the outer ring of indirectly reached code to stay readable. It is still listed here.</p>`;
1346
+ return h;
1347
+ }
1348
+ /* ---------- tours ---------- */
1349
+ const TOURS = D.tours || [];
1350
+ function tourHtml() {
1351
+ if (!S.tour) return "";
1352
+ const t = TOURS[S.tour.idx], st = t.stops[S.tour.stop], n = t.stops.length;
1353
+ const pick = TOURS.length > 1 ? `<select id="tour-pick" aria-label="Tour">${TOURS.map((x, k) => `<option value="${k}"${k === S.tour.idx ? " selected" : ""}>${esc(x.title)}</option>`).join("")}</select>` : `<b>${esc(t.title)}</b>`;
1354
+ return `<section class="tour" aria-label="Tour"><div class="tour-top">${pick}<button id="tour-end" aria-label="End the tour">End</button></div>
1355
+ <div class="eyebrow">Stop ${S.tour.stop + 1} of ${n}${t.source.startsWith("leyline-tour") ? ` <span class="tag">from the graph</span>` : ` <span class="tag heur">written by ${esc(t.source === "mcp" ? "an agent" : t.source)}</span>`}</div>
1356
+ <h2>${esc(st.title || "Stop " + (S.tour.stop + 1))}</h2><p class="note">${esc(st.text)}</p>
1357
+ <div class="tour-nav"><button id="tour-prev"${S.tour.stop === 0 ? " disabled" : ""}>Back</button><button id="tour-back">Show this stop</button><button id="tour-next" class="primary"${S.tour.stop === n - 1 ? " disabled" : ""}>Next</button></div></section>`;
1358
+ }
1359
+ // Put the map on whatever the current stop points at.
1360
+ function tourShow() {
1361
+ const st = TOURS[S.tour.idx].stops[S.tour.stop];
1362
+ $("tab-tour").setAttribute("aria-pressed", "true");
1363
+ if (st.kind === "flow") { const fi = FLOWS.findIndex((f) => f.id === st.ref); if (fi >= 0) return openFlow(fi, null); }
1364
+ if (st.kind === "pattern" || st.kind === "view") { const vi = VIEWS.findIndex((v) => v.id === st.ref); if (vi >= 0) { S.viewIdx = vi; S.sel = null; if (S.mode === "views") { viewSetup(); render(true); viewKey(); } else setMode("views"); return; } }
1365
+ if (S.mode !== "map") setMode("map");
1366
+ if (st.kind === "repo" || st.i == null) return top();
1367
+ goTo(st.i);
1368
+ }
1369
+ function reviewHtml(v) {
1370
+ const r = v.review, p = r.prediction, g = r.graph, t = r.tests;
1371
+ const lvl = (x) => `<span class="tag ${x.level === "high" ? "risk-high" : x.level === "medium" ? "risk-medium" : ""}">${x.level}</span>`;
1372
+ const nrow = (n, sub) => ({ go: n.i, name: n.name, sub: sub != null ? sub : [n.why, n.path ? n.path + (n.line ? ":" + n.line : "") : ""].filter(Boolean).join(" · "), ct: "" });
1373
+ let h = `<div class="eyebrow">Change review <span class="tag heur">graph before and after</span></div><h2>${esc(v.title.replace(/^Review: /, ""))}</h2><p class="note">${esc(v.narrative || "")}</p>`;
1374
+ h += `<div class="facts"><div><b>${p.as_predicted}/${p.predicted}</b><span>predicted edits made</span></div><div><b>${p.not_predicted}</b><span>not predicted</span></div><div><b>${g.structure.new_dependencies.length}</b><span>new module links</span></div><div><b>${t ? t.after.passed + "/" + t.after.total : "–"}</b><span>tests passing</span></div></div>`;
1375
+ h += `<h3>Findings</h3>` + (r.verdict.length ? r.verdict.map((x) => `<p class="note">${lvl(x)} ${esc(x.what)}</p>`).join("") : `<p class="note">Nothing flagged: the edits match the proposal, no new links between modules, no rule or test newly failing.</p>`);
1376
+ const byRole = (role) => v.marks.filter((m) => m.role === role).map((m) => ({ go: m.i, name: N[m.i].k === "callable" ? qual(m.i) : N[m.i].n, sub: [m.note, N[moduleOf[m.i]] ? "in " + N[moduleOf[m.i]].n : ""].filter(Boolean).join(" · "), ct: "" }));
1377
+ h += listHtml("Edited, not in the proposal", r.not_predicted.map((n) => nrow(n)), r.not_predicted.length);
1378
+ h += listHtml("In the proposal, not edited", r.predicted_untouched.map((n) => nrow(n, n.note)), r.predicted_untouched.length);
1379
+ h += listHtml("Edited as predicted", byRole("edited as predicted"), byRole("edited as predicted").length);
1380
+ h += listHtml("New, as declared", byRole("new, as declared"), byRole("new, as declared").length);
1381
+ h += listHtml("Signatures changed", g.nodes.resigned.map((n) => nrow(n, `(${n.was}) → (${n.now})`)), g.nodes.resigned.length);
1382
+ h += listHtml("New links between modules", g.structure.new_dependencies.map((d) => ({ go: d.fi, name: `${d.from} → ${d.to}`, sub: "", ct: d.links + (d.links === 1 ? " link" : " links") })), g.structure.new_dependencies.length);
1383
+ h += listHtml("Links between modules that went away", g.structure.removed_dependencies.map((d) => ({ name: `${d.from} → ${d.to}`, sub: "", ct: d.links + "" })), g.structure.removed_dependencies.length);
1384
+ if (g.flows.changed) {
1385
+ h += listHtml(`Flows now pass through (${g.flows.changed} flows changed path)`, g.flows.now_pass_through.map((n) => ({ go: n.i, name: n.name, sub: "", ct: n.flows + " flows" })), g.flows.now_pass_through.length);
1386
+ h += listHtml("Flows no longer pass through", g.flows.no_longer_pass_through.map((n) => ({ go: n.i, name: n.name, sub: "", ct: n.flows + " flows" })), g.flows.no_longer_pass_through.length);
1387
+ }
1388
+ const newly = new Set(r.rules.new_violations.map((x) => x.id));
1389
+ const rules = r.rules.all || [];
1390
+ if (rules.length) h += `<h3>Rules <span>${rules.length - r.rules.failing} of ${rules.length} hold</span></h3>` + rules.map((x) => `<p class="note"><span class="tag ${x.passes ? "" : newly.has(x.id) ? "risk-high" : "risk-medium"}">${x.passes ? "holds" : newly.has(x.id) ? "newly broken" : "broken before too"}</span> ${esc(x.kind === "forbid" ? `${x.from} must not use ${x.to}` : x.kind === "no_cycle" ? `no cycles between ${x.from}` : `${x.from} must be on a test's path`)}${x.status === "suggested" ? ` <span class="tag heur">suggested</span>` : ""}${x.passes ? "" : ` · ${x.violations} ${x.violations === 1 ? "violation" : "violations"}`}</p>`).join("");
1391
+ if (t) {
1392
+ h += `<h3>Tests</h3><p class="note">${t.before.passed} of ${t.before.total} passed before, ${t.after.passed} of ${t.after.total} after.</p>`;
1393
+ h += listHtml("Now failing", t.newly_failing.map((x) => ({ name: x.name, sub: x.message || "", ct: "" })), t.newly_failing.length);
1394
+ h += listHtml("Tests added", t.added.map((x) => ({ name: x, sub: "", ct: "" })), t.added.length);
1395
+ h += listHtml("Tests removed", t.removed.map((x) => ({ name: x, sub: "", ct: "" })), t.removed.length);
1396
+ } else h += `<h3>Tests</h3><p class="note">No test runs were recorded for this change.</p>`;
1397
+ h += `<p class="note">${esc(r.limits || "")}</p>`;
1398
+ return h;
1399
+ }
1400
+ function viewNodeHtml(i) {
1401
+ const ms = VW.marksIn.get(i) || [];
1402
+ const rows = ms.map((m) => ({ go: m.i, name: m.i === i ? N[i].n : qual(m.i), sub: m.note, ct: ROLE_LABEL[m.role] || m.role }));
1403
+ return `<div class="eyebrow">In this view</div>` + listHtml("Why it is here", rows, rows.length).replace("<h3>", `<h3 style="margin-top:4px">`) + `<div style="height:14px"></div>` + nodeHtml(i);
1404
+ }
1405
+ function openFlow(fi, node) { S.flow = fi; S.sel = node != null ? { node } : null; setMode("flows"); const cur = $("flowview").querySelector("[aria-current]"); if (cur) cur.scrollIntoView({ block: "center" }); }
1406
+ // Map and Changes are on the first screen; the rest sits behind More.
1407
+ const MORE = { flows: "Flows", matrix: "Matrix", views: "Saved views" };
1408
+ const usesView = (mode) => mode === "views" || (mode === "change" && S.change != null);
1409
+ function setMode(mode) {
1410
+ S.mode = mode; menu(false);
1411
+ $("tab-map").setAttribute("aria-pressed", mode === "map"); $("tab-change").setAttribute("aria-pressed", mode === "change");
1412
+ for (const m in MORE) $("tab-" + m).setAttribute("aria-pressed", mode === m);
1413
+ $("tab-more").setAttribute("aria-pressed", mode in MORE); $("tab-more").textContent = mode in MORE ? "More: " + MORE[mode] : "More";
1414
+ const onMap = mode === "map" || mode === "views" || mode === "change";
1415
+ $("map").toggleAttribute("hidden", !onMap); $("matrix").hidden = mode !== "matrix"; $("flowview").hidden = mode !== "flows";
1416
+ $("hint").hidden = mode !== "map"; $("viewkey").hidden = mode !== "views";
1417
+ $("crumbs").style.visibility = mode === "flows" || usesView(mode) ? "hidden" : "";
1418
+ if (usesView(mode)) { if (S.level !== "view") { S.prev = { level: S.level, focus: S.focus }; S.level = "view"; S.focus = null; } S.sel = null; viewSetup(); viewKey(); draw(); }
1419
+ else if (S.level === "view") { S.level = S.prev ? S.prev.level : WS ? "ws" : "repo"; S.focus = S.prev ? S.prev.focus : WS ? null : repoIdx; S.sel = null; crumbs(); draw(); }
1420
+ else if (mode === "map") draw();
1421
+ tools();
1422
+ if (mode === "matrix") drawMatrix(); else if (mode === "flows") drawFlow(); else requestAnimationFrame(fit);
1423
+ side();
1424
+ }
1425
+ function menu(open) { $("menu").hidden = !open; $("tab-more").setAttribute("aria-expanded", open); }
1426
+ $("tab-map").onclick = () => setMode("map");
1427
+ $("tab-change").onclick = () => { if (S.mode === "change" && CHANGES.length > 1) S.change = null; else if (S.change == null && CHANGES.length === 1) return openChange(0); setMode("change"); };
1428
+ $("tab-more").onclick = (e) => { e.stopPropagation(); menu($("menu").hidden); };
1429
+ $("tab-matrix").onclick = () => setMode("matrix");
1430
+ $("tab-flows").onclick = () => { if (S.sel && S.sel.edge) S.sel = null; setMode("flows"); };
1431
+ $("tab-views").onclick = () => setMode("views");
1432
+ $("tab-tour").onclick = () => { menu(false); if (!TOURS.length) return; if (!S.tour) S.tour = { idx: 0, stop: 0 }; tourShow(); };
1433
+ if (!TOURS.length) $("tab-tour").hidden = true;
1434
+ document.addEventListener("click", (e) => { if (!e.target.closest(".morewrap")) menu(false); });
1435
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") menu(false); });
1436
+
1437
+ /* ---------- changes ---------- */
1438
+ // A change planned with `leyline plan`: its page (leyline.md), its tasks tied to code, the view of what it
1439
+ // is expected to touch and, after `leyline check`, what it did touch.
1440
+ const CHANGES = D.changes || [];
1441
+ const viewById = (id) => VIEWS.find((v) => v.id === id);
1442
+ function changeView(c, phase) {
1443
+ if (phase === "after" && c.after) return { kind: "change", title: c.title, marks: c.after, legend: {} };
1444
+ const v = viewById(c.plan_view) || { marks: [] };
1445
+ const marks = v.marks.slice();
1446
+ // Code that is not written yet has no box: mark where it goes.
1447
+ for (const t of c.tasks) for (const nw of t.new) if (nw.pi != null) marks.push({ i: nw.pi, role: "new", note: "new: " + nw.name });
1448
+ // A test that runs through the change is shown as a test, as leyline.md counts it, not only as code it reaches.
1449
+ const tests = new Set((v.tests_to_run || []).map((t) => t.i).filter((i) => i != null));
1450
+ return { ...v, kind: "change", marks: marks.map((m) => (tests.has(m.i) && (m.role === "direct" || m.role === "indirect") ? { ...m, role: "test" } : m)) };
1451
+ }
1452
+ function curView() { return S.mode === "change" && S.change != null ? changeView(CHANGES[S.change], S.phase) : VIEWS[S.viewIdx]; }
1453
+ function openChange(k) {
1454
+ S.change = k; S.phase = CHANGES[k].after ? "after" : "plan"; S.pick = null;
1455
+ try { history.replaceState(null, "", "#change=" + encodeURIComponent(CHANGES[k].name)); } catch (e) { /* a sandboxed page */ }
1456
+ setMode("change"); crumbs();
1457
+ }
1458
+ function setPhase(ph) { S.phase = ph; S.sel = null; viewSetup(); draw(); tools(); side(); requestAnimationFrame(fit); }
1459
+ // Every box that holds a marked node, with the strongest role inside it: the change drawn over the ordinary map.
1460
+ let overKey = null, overMap = null;
1461
+ function overlay() {
1462
+ const key = S.change + ":" + S.phase;
1463
+ if (key === overKey) return overMap;
1464
+ const rank = (r) => { const k = ROLE_ORDER.indexOf(r); return k < 0 ? 99 : k; }, m = new Map();
1465
+ const put = (c, r) => { if (!m.has(c) || rank(r) < rank(m.get(c))) m.set(c, r); };
1466
+ for (const mk of changeView(CHANGES[S.change], S.phase).marks) for (let c = mk.i; c != null; c = N[c].p) { put(c, mk.role); if (sysOf.has(c)) put(sysOf.get(c), mk.role); }
1467
+ overKey = key; overMap = m;
1468
+ return m;
1469
+ }
1470
+ const normName = (x) => String(x).toLowerCase().replace(/test_/g, "").replace(/[^a-z0-9]+/g, " ").trim();
1471
+ const scenarioTest = (c, name) => { const sc = c.scenarios.find((x) => x.name === name); if (sc && sc.test != null) return sc.test; const k = N.findIndex((n) => (n.k === "test" || (n.k === "callable" && /^test/i.test(n.n))) && normName(n.n) === normName(name)); return k >= 0 ? k : null; };
1472
+ function pickNodes(c, p) {
1473
+ if (!p) return [];
1474
+ if (p.task) { const t = c.tasks.find((x) => x.key === p.task); return t ? [...t.nodes, ...t.into, ...t.new.map((x) => x.pi).filter((x) => x != null)] : []; }
1475
+ const t = scenarioTest(c, p.scenario); return t != null ? [t] : [];
1476
+ }
1477
+ function picked() {
1478
+ if (S.change == null || !S.pick) return null;
1479
+ const set = new Set();
1480
+ for (const i of pickNodes(CHANGES[S.change], S.pick)) for (let c = i; c != null; c = N[c].p) { set.add(c); if (sysOf.has(c)) set.add(sysOf.get(c)); }
1481
+ return set;
1482
+ }
1483
+ // leyline.md as blocks. It is generated, so a small reader covers it: headings, paragraphs, lists, tables.
1484
+ function mdBlocks(text) {
1485
+ const lines = text.replace(/<!--[\s\S]*?-->/g, "").split(/\r?\n/), out = [];
1486
+ let k = 0, section = "";
1487
+ while (k < lines.length) {
1488
+ const ln = lines[k];
1489
+ if (!ln.trim()) { k++; continue; }
1490
+ if (ln.startsWith("# ")) { out.push({ t: "title", text: ln.slice(2) }); k++; continue; }
1491
+ if (ln.startsWith("## ")) { section = ln.slice(3); out.push({ t: "h", text: section }); k++; continue; }
1492
+ if (ln.startsWith("### ")) { out.push({ t: "sub", text: ln.slice(4) }); k++; continue; }
1493
+ // A ```mermaid block (a sequence diagram drawn from the map): this page has no renderer, so it shows the source.
1494
+ if (ln.startsWith("```")) { const code = []; k++; while (k < lines.length && !lines[k].startsWith("```")) code.push(lines[k++]); k++; out.push({ t: "pre", lang: ln.slice(3).trim(), text: code.join("\n") }); continue; }
1495
+ if (ln.startsWith("|")) { const rows = []; while (k < lines.length && lines[k].startsWith("|")) rows.push(lines[k++]); out.push({ t: "table", rows: rows.map((r) => r.split("|").slice(1, -1).map((c) => c.trim())), section }); continue; }
1496
+ if (/^\s*- /.test(ln)) { const items = []; while (k < lines.length && /^\s*- /.test(lines[k])) items.push(lines[k++].replace(/^\s*- /, "")); out.push({ t: "ul", items }); continue; }
1497
+ const para = []; while (k < lines.length && lines[k].trim() && !/^(#|\||\s*- |```)/.test(lines[k])) para.push(lines[k++]);
1498
+ out.push({ t: "p", text: para.join(" ") });
1499
+ }
1500
+ return out;
1501
+ }
1502
+ const inline = (t) => esc(t).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/(^|[^*\w])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>");
1503
+ function tableHtml(b) {
1504
+ const [head, , ...body] = b.rows;
1505
+ const isTask = head[0] === "Task", isScen = head[0] === "Scenario", result = head[1] === "Result";
1506
+ const cur = S.pick || {};
1507
+ return `<div class="tbl"><table><thead><tr>${head.map((h) => `<th>${inline(h)}</th>`).join("")}</tr></thead><tbody>${body.map((r) => {
1508
+ const key = isTask ? r[0].split(" ")[0] : isScen ? r[0] : null;
1509
+ const on = key != null && (isTask ? cur.task === key : cur.scenario === key);
1510
+ const attr = isTask ? ` data-task="${esc(key)}"` : isScen ? ` data-scenario="${esc(key)}"` : "";
1511
+ return `<tr${attr}${on ? ` aria-current="true"` : ""}${attr ? ' tabindex="0"' : ""}>${r.map((c, j) => `<td${result && j === 1 ? ` class="res-${esc(c.split(" (")[0].replace(/\s+/g, "-"))}"` : ""}>${inline(c)}</td>`).join("")}</tr>`;
1512
+ }).join("")}</tbody></table></div>`;
1513
+ }
1514
+ function keyHtml(c) {
1515
+ const have = new Set(changeView(c, S.phase).marks.map((m) => m.role));
1516
+ return `<ul class="keylist">${ROLE_ORDER.filter((r) => have.has(r)).map((r) => `<li><span class="key"><b class="k-${r}"></b></span>${esc(ROLE_LABEL[r])}</li>`).join("")}</ul>`;
1517
+ }
1518
+ function pickHtml(c) {
1519
+ if (!S.pick) return `<p class="note">Select a task or a scenario below to outline its code on the map. Double-click a box to open it on the full map.</p>`;
1520
+ const ns = pickNodes(c, S.pick), shown = picked(), onMap = [...L.boxes.keys()].some((i) => shown && shown.has(i));
1521
+ const what = S.pick.task ? "Task " + S.pick.task : "Scenario " + S.pick.scenario;
1522
+ const fi = S.pick.scenario && ns.length ? FLOWS.findIndex((f) => f.entry === ns[0]) : -1;
1523
+ const t = S.pick.task ? c.tasks.find((x) => x.key === S.pick.task) : null;
1524
+ const newOnly = t && !t.nodes.length && !t.into.length;
1525
+ const why = !ns.length ? (S.pick.scenario ? "Its test is not on the map yet: it is to be written, with this name." : "It names no code on the map.") : !onMap ? (newOnly ? "Its code is new, so it is not on the map; its box shows where it goes once the plan is followed." : "Its code is not in this picture; open it on the full map.") : "";
1526
+ return `<p class="note"><b>${esc(what)}</b> is outlined on the map. ${why} ${ns.length ? `<button class="chip" data-go="${ns[0]}">Show on the full map</button>` : ""} ${fi >= 0 ? `<button class="chip" data-flow="${fi}">Trace what the test runs</button>` : ""} <button class="chip" id="unpick">Clear</button></p>`;
1527
+ }
1528
+ function stateClass(text) { return /done as agreed\./.test(text) ? "ok" : /ready to implement\./.test(text) ? "ready" : "todo"; }
1529
+ function changeHtml() {
1530
+ if (!CHANGES.length) return `<div class="eyebrow">Changes</div><h2>No change is planned yet</h2>
1531
+ <p class="note">A change is planned as an OpenSpec folder, <code>openspec/changes/&lt;id&gt;/</code>, whose tasks say what code will be written and whose scenarios say how you will know it works. Ask your agent to write one (the leyline-spec skill says how), then run <code>leyline plan &lt;id&gt;</code>.</p>
1532
+ <p class="note">The map page is then rewritten with the change on it: what will be written, what else must be edited with it, and which tests run through it. After <code>leyline check</code> it also shows what was done.</p>`;
1533
+ if (S.change == null) return `<div class="eyebrow">Changes</div><h2>${CHANGES.length} planned changes</h2>
1534
+ <p class="note">Pick one to see on the map what it will write and, once it has been checked, what was done.</p>
1535
+ <ul class="changes">${CHANGES.map((c, k) => { const st = c.page && (c.page.match(/\*\*State:([^*]+)\*\*/) || [])[1]; return `<li><button data-change="${k}"><b>${esc(c.title)}</b><small>${st ? "State:" + esc(st) : c.after ? "checked" : "planned"}</small><small>${esc(c.folder || c.id)}</small></button></li>`; }).join("")}</ul>`;
1536
+ const c = CHANGES[S.change];
1537
+ let h = CHANGES.length > 1 ? `<div class="banner"><span>Change ${S.change + 1} of ${CHANGES.length}</span><button id="all-changes">All changes</button></div>` : "";
1538
+ h += `<div class="eyebrow">Change <span class="tag">${esc(c.folder || c.id)}</span></div><h2>${esc(c.title)}</h2>`;
1539
+ const onmap = `<section class="onmap" aria-label="On the map"><div class="eyebrow">On the map: ${S.phase === "after" ? "what was done, from the last check" : "what the plan will change"}</div>${keyHtml(c)}${pickHtml(c)}</section>`;
1540
+ if (!c.page) {
1541
+ const v = viewById(c.plan_view) || {};
1542
+ return h + `<p class="note">${esc(v.narrative || "")}</p><p class="note">This change was assessed without a spec folder, so it has no leyline.md page.</p>` + onmap + `<div class="md">${listHtml("Tests to run", (v.tests_to_run || []).slice(0, 30).map((t) => ({ go: t.i, name: t.name, ct: "" })), (v.tests_to_run || []).length)}</div>`;
1543
+ }
1544
+ let body = "", placed = false;
1545
+ for (const b of mdBlocks(c.page)) {
1546
+ if (b.t === "title") continue;
1547
+ if (b.t === "p" && b.text.startsWith("**State:")) { h += `<p class="state ${stateClass(b.text)}">${inline(b.text)}</p>`; continue; }
1548
+ if (b.t === "p" && /^The one-page plan for/.test(b.text)) continue;
1549
+ if (b.t === "h" && !placed) { body += onmap; placed = true; }
1550
+ body += b.t === "h" ? `<h3>${inline(b.text)}</h3>` : b.t === "sub" ? `<h4>${inline(b.text)}</h4>` : b.t === "pre" ? `<pre class="code diagram" data-lang="${esc(b.lang)}"><code>${esc(b.text)}</code></pre>` : b.t === "p" ? `<p>${inline(b.text)}</p>` : b.t === "ul" ? `<ul>${b.items.map((x) => `<li>${inline(x)}</li>`).join("")}</ul>` : tableHtml(b);
1551
+ }
1552
+ if (!placed) body += onmap;
1553
+ return h + `<div class="md">${body}<p class="note">This is <code>${esc(c.folder)}leyline.md</code>, written by <code>leyline plan</code> and <code>leyline check</code>. To change the plan, edit the spec files beside it and run <code>leyline plan ${esc(c.name)}</code> again.</p></div>`;
1554
+ }
1555
+ const backHtml = () => `<div class="banner"><span>${esc(CHANGES[S.change].title)}</span><button id="to-change">Back to the change</button></div>`;
1556
+ const shownHtml = () => `<div class="banner"><span>The map shows the change <b>${esc(CHANGES[S.change].title)}</b>: ${S.phase === "after" ? "what was done" : "what the plan will change"}.</span><button id="to-change">Back to the change</button><button id="hide-change">Hide</button></div>`;
1557
+ function wireChange(s) {
1558
+ for (const b of s.querySelectorAll("[data-change]")) b.onclick = () => openChange(+b.dataset.change);
1559
+ if ($("all-changes")) $("all-changes").onclick = () => { S.change = null; S.pick = null; setMode("change"); };
1560
+ if ($("to-change")) $("to-change").onclick = () => { S.sel = null; if (S.mode === "change") { mark(); side(); } else setMode("change"); };
1561
+ if ($("hide-change")) $("hide-change").onclick = () => { S.change = null; S.pick = null; draw(); side(); };
1562
+ if ($("unpick")) $("unpick").onclick = () => { S.pick = null; mark(); side(); };
1563
+ const choose = (p) => { const same = S.pick && S.pick.task === p.task && S.pick.scenario === p.scenario; S.pick = same ? null : p; mark(); const top = s.scrollTop; side(); s.scrollTop = top; };
1564
+ for (const r of s.querySelectorAll("[data-task]")) { r.onclick = () => choose({ task: r.dataset.task }); r.onkeydown = (e) => { if (e.key === "Enter") r.click(); }; }
1565
+ for (const r of s.querySelectorAll("[data-scenario]")) { r.onclick = () => choose({ scenario: r.dataset.scenario }); r.onkeydown = (e) => { if (e.key === "Enter") r.click(); }; }
1566
+ }
1567
+ /* ---------- workspace ---------- */
1568
+ function wsHtml() {
1569
+ const pairs = new Map(), called = new Map();
1570
+ for (const Lk of LINKS) {
1571
+ const a = repoOf[Lk[1]], b = repoOf[Lk[2]];
1572
+ if (a == null || b == null || a === b) continue;
1573
+ const key = a + ">" + b; let p = pairs.get(key); if (!p) pairs.set(key, p = { a, b, n: 0, kinds: {} });
1574
+ p.n += Lk[4]; p.kinds[linkLabel(Lk)] = (p.kinds[linkLabel(Lk)] || 0) + Lk[4];
1575
+ if (Lk[0] === "calls") { const c = called.get(Lk[2]) || new Set(); c.add(Lk[1]); called.set(Lk[2], c); }
1576
+ }
1577
+ const crossing = FLOWS.filter((f) => f.steps.some((st) => repoOf[st[0]] !== repoOf[f.entry])).length;
1578
+ const facts = [[REPOS.length, "repositories"], [N.filter((n) => n.k === "module").length, "modules"], [N.filter((n) => n.k === "file").length, "files"], [big(REPOS.reduce((t, r) => t + linesOf(r), 0)), "lines"]];
1579
+ const prs = [...pairs.values()].sort((x, y) => y.n - x.n);
1580
+ const top = [...called.entries()].sort((x, y) => y[1].size - x[1].size).slice(0, 12);
1581
+ return `<div class="eyebrow">Workspace</div><h2>${REPOS.map((r) => esc(N[r].n)).join(" + ")}</h2>
1582
+ <div class="facts">${facts.map(([v, l]) => `<div><b>${v}</b><span>${l}</span></div>`).join("")}</div>
1583
+ <p class="note">One store holds ${REPOS.length} repositories, indexed together so that a call from one into another links to the code it reaches. Each box is a repository; an arrow from A to B means code in A uses code in B, and its number counts the links. Double-click a repository to see its modules; links into the others stay on that map, dashed and in their own colour.</p>
1584
+ ${CHANGES.length && S.change == null ? `<p class="note">${CHANGES.length === 1 ? "One change is" : CHANGES.length + " changes are"} planned. Open <b>Changes</b> to see on the map what ${CHANGES.length === 1 ? "it" : "each"} will write.</p>` : ""}
1585
+ ${listHtml("Repositories", REPOS.map((r) => ({ go: r, name: N[r].n, sub: repoMeta(r).folder || repoMeta(r).url || "", ct: big(linesOf(r)) + " lines" })), REPOS.length)}
1586
+ <h3>Links between repositories <span>${prs.length}</span></h3>${prs.length ? `<ul class="list">${prs.map((p) => `<li><button data-edge="${p.a}>${p.b}"><span class="nm">${esc(N[p.a].n)} → ${esc(N[p.b].n)}<small>${esc(Object.entries(p.kinds).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k} ${v.toLocaleString()}`).join(" · "))}</small></span><span class="ct">${p.n.toLocaleString()}</span></button></li>`).join("")}</ul>` : `<p class="note">None found: no code in one repository uses code in another.</p>`}
1587
+ ${listHtml("Most used from another repository", top.map(([d, cs]) => ({ go: d, name: qual(d), sub: "in " + N[repoOf[d]].n + " · called from " + [...new Set([...cs].map((c) => N[repoOf[c]].n))].join(", "), ct: cs.size + " callers" })), called.size)}
1588
+ <h3>Flows that cross</h3><p class="note">${crossing.toLocaleString()} of ${FLOWS.length.toLocaleString()} flows (what a test or an entry point runs through) pass into another repository. Find them under More, Flows.</p>`;
1589
+ }
1590
+
1591
+ /* ---------- search ---------- */
1592
+ const searchable = N.filter((n) => ["part", "system", "module", "file", "type", "callable", "field", "test"].includes(n.k) && n.n !== "<module>" && n.n !== "<top-level>");
1593
+ const rank = { part: 0, system: 0, module: 0, type: 1, file: 2, callable: 3, field: 4, test: 5 };
1594
+ $("q").addEventListener("input", () => {
1595
+ const q = $("q").value.trim().toLowerCase(), box = $("results");
1596
+ if (q.length < 2) { box.hidden = true; return; }
1597
+ const words = q.split(/[\s.]+/).filter(Boolean);
1598
+ const hits = [];
1599
+ for (const n of searchable) {
1600
+ const hay = (n.i.split(":").slice(2).join(":") + " " + (n.f || "")).toLowerCase(), name = n.n.toLowerCase();
1601
+ if (!words.every((w) => hay.includes(w))) continue;
1602
+ const last = words[words.length - 1];
1603
+ hits.push([(name === q ? -2 : name.includes(q) && words.length > 1 ? -1 : name === last ? 0 : name.startsWith(last) ? 1 : name.includes(last) ? 2 : 3) * 10 + rank[n.k], n.i.length, n]);
1604
+ }
1605
+ hits.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
1606
+ box.innerHTML = hits.slice(0, 14).map(([, , n]) => `<button data-go="${n.idx}"><b>${esc(n.k === "callable" || n.k === "field" ? qual(n.idx) : n.n)}</b><span>${esc(kindName(n.idx))}${WS && repoOf[n.idx] != null ? " · " + esc(N[repoOf[n.idx]].n) : ""} · ${esc(where(n.idx))}</span></button>`).join("") || `<button disabled><span>Nothing matches “${esc(q)}”.</span></button>`;
1607
+ box.hidden = false;
1608
+ for (const b of box.querySelectorAll("[data-go]")) b.onclick = () => { box.hidden = true; $("q").value = ""; if (S.mode === "matrix" || S.mode === "views" || S.mode === "change") setMode("map"); goTo(+b.dataset.go); };
1609
+ });
1610
+ $("q").addEventListener("keydown", (e) => { if (e.key === "Escape") { $("results").hidden = true; $("q").blur(); } if (e.key === "Enter") { const b = $("results").querySelector("[data-go]"); if (b) b.click(); } });
1611
+ document.addEventListener("click", (e) => { if (!e.target.closest(".search")) $("results").hidden = true; });
1612
+
1613
+ /* ---------- start ---------- */
1614
+ $("hint-cross").hidden = !WS;
1615
+ $("change-count").textContent = CHANGES.length ? " " + CHANGES.length : "";
1616
+ const asked = decodeURIComponent((location.hash.match(/change=([^&]+)/) || [])[1] || "") || (D.start && D.start.change);
1617
+ const startAt = asked ? CHANGES.findIndex((c) => c.name === asked || c.id === asked) : -1;
1618
+ if (startAt >= 0) openChange(startAt); else render(true);
1619
+ window.addEventListener("resize", () => { if (S.mode === "map") fit(); });
1620
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { draw(); fit(); });
1621
+ })();
1622
+ </script>