shapeup-sdlc 3.0.2 → 3.1.0
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.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +1 -0
- package/README.md +2 -2
- package/commands/hill.md +10 -0
- package/kernel/reduce/hill.mjs +19 -21
- package/package.json +1 -1
- package/skills/hill-chart/SKILL.md +151 -0
- package/skills/hill-chart/assets/dashboard.template.html +1432 -0
- package/skills/tech-lead/references/protocol.md +17 -0
- package/skills/tech-lead/workflows/shapeup-run.js +45 -11
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
<meta charset="utf-8">
|
|
2
|
+
<title>Hill Chart</title>
|
|
3
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
4
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
|
|
5
|
+
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
--bg: #f2f5f8;
|
|
9
|
+
--canvas: #f7f9fb;
|
|
10
|
+
--canvas-dot: #dbe2e9;
|
|
11
|
+
--surface: #ffffff;
|
|
12
|
+
--surface-2: #edf1f5;
|
|
13
|
+
--border: #dde3ea;
|
|
14
|
+
--border-soft: #e8edf2;
|
|
15
|
+
--text: #1a222c;
|
|
16
|
+
--text-dim: #5c6b7c;
|
|
17
|
+
--text-faint: #8f9dac;
|
|
18
|
+
--accent: #e35a37;
|
|
19
|
+
--accent-strong: #c8481f;
|
|
20
|
+
--accent-soft: #fce4da;
|
|
21
|
+
--good: #1f9d57;
|
|
22
|
+
--good-soft: #e0f4e8;
|
|
23
|
+
--bad: #d1402f;
|
|
24
|
+
--bad-soft: #fbe4e0;
|
|
25
|
+
--warn: #c98411;
|
|
26
|
+
--warn-soft: #faedd3;
|
|
27
|
+
--pending: #8f9dac;
|
|
28
|
+
--pending-soft: #e9edf2;
|
|
29
|
+
--focus: #2563eb;
|
|
30
|
+
|
|
31
|
+
--font-display: 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
32
|
+
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
33
|
+
--font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
|
34
|
+
|
|
35
|
+
--n-run: #0e7490;
|
|
36
|
+
--n-order: #1d4ed8;
|
|
37
|
+
--n-result: #4f46e5;
|
|
38
|
+
--n-verdict: #7c3aed;
|
|
39
|
+
--n-trial: #0369a1;
|
|
40
|
+
--n-gate: #0f766e;
|
|
41
|
+
--n-scope: #c2410c;
|
|
42
|
+
--n-usecase: #b45309;
|
|
43
|
+
--n-requirement: #be185d;
|
|
44
|
+
--n-seam: #a16207;
|
|
45
|
+
--n-other: #52606d;
|
|
46
|
+
|
|
47
|
+
--hill-uphill-unknown: #94a3b8;
|
|
48
|
+
--hill-uphill-solved: #eab308;
|
|
49
|
+
--hill-downhill-execution: #3b82f6;
|
|
50
|
+
--hill-finished: #1f9d57;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (prefers-color-scheme: dark) {
|
|
54
|
+
:root:not([data-theme="light"]) {
|
|
55
|
+
--bg: #12161d;
|
|
56
|
+
--canvas: #0f131a;
|
|
57
|
+
--canvas-dot: #1e2530;
|
|
58
|
+
--surface: #1a1f28;
|
|
59
|
+
--surface-2: #212733;
|
|
60
|
+
--border: #313a47;
|
|
61
|
+
--border-soft: #262e3a;
|
|
62
|
+
--text: #eef1f5;
|
|
63
|
+
--text-dim: #9aa7b6;
|
|
64
|
+
--text-faint: #5f6b7a;
|
|
65
|
+
--accent: #ff7d52;
|
|
66
|
+
--accent-strong: #ff9873;
|
|
67
|
+
--accent-soft: #3a2418;
|
|
68
|
+
--good: #46c982;
|
|
69
|
+
--good-soft: #163425;
|
|
70
|
+
--bad: #f0685c;
|
|
71
|
+
--bad-soft: #3a1e1c;
|
|
72
|
+
--warn: #e6ac3d;
|
|
73
|
+
--warn-soft: #3a2e12;
|
|
74
|
+
--pending: #7c8aa0;
|
|
75
|
+
--pending-soft: #232a35;
|
|
76
|
+
--focus: #60a5fa;
|
|
77
|
+
|
|
78
|
+
--n-run: #22d3ee; --n-order: #60a5fa; --n-result: #a5b4fc; --n-verdict: #c4b5fd;
|
|
79
|
+
--n-trial: #38bdf8; --n-gate: #2dd4bf; --n-scope: #fb923c; --n-usecase: #fbbf24;
|
|
80
|
+
--n-requirement: #f472b6; --n-seam: #eab308; --n-other: #94a3b8;
|
|
81
|
+
|
|
82
|
+
--hill-uphill-unknown: #64748b; --hill-uphill-solved: #eab308;
|
|
83
|
+
--hill-downhill-execution: #60a5fa; --hill-finished: #46c982;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
:root[data-theme="dark"] {
|
|
87
|
+
--bg: #12161d; --canvas: #0f131a; --canvas-dot: #1e2530;
|
|
88
|
+
--surface: #1a1f28; --surface-2: #212733; --border: #313a47; --border-soft: #262e3a;
|
|
89
|
+
--text: #eef1f5; --text-dim: #9aa7b6; --text-faint: #5f6b7a;
|
|
90
|
+
--accent: #ff7d52; --accent-strong: #ff9873; --accent-soft: #3a2418;
|
|
91
|
+
--good: #46c982; --good-soft: #163425; --bad: #f0685c; --bad-soft: #3a1e1c;
|
|
92
|
+
--warn: #e6ac3d; --warn-soft: #3a2e12; --pending: #7c8aa0; --pending-soft: #232a35;
|
|
93
|
+
--focus: #60a5fa;
|
|
94
|
+
--n-run: #22d3ee; --n-order: #60a5fa; --n-result: #a5b4fc; --n-verdict: #c4b5fd;
|
|
95
|
+
--n-trial: #38bdf8; --n-gate: #2dd4bf; --n-scope: #fb923c; --n-usecase: #fbbf24;
|
|
96
|
+
--n-requirement: #f472b6; --n-seam: #eab308; --n-other: #94a3b8;
|
|
97
|
+
--hill-uphill-unknown: #64748b; --hill-uphill-solved: #eab308;
|
|
98
|
+
--hill-downhill-execution: #60a5fa; --hill-finished: #46c982;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
* { box-sizing: border-box; }
|
|
102
|
+
html, body { height: 100%; }
|
|
103
|
+
body {
|
|
104
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
105
|
+
font-family: var(--font-body); font-size: 14.5px; line-height: 1.5;
|
|
106
|
+
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
|
|
107
|
+
}
|
|
108
|
+
h1, h2, h3 { font-family: var(--font-display); text-wrap: balance; }
|
|
109
|
+
::selection { background: var(--accent-soft); }
|
|
110
|
+
button, input, textarea { font-family: inherit; color: inherit; }
|
|
111
|
+
button { cursor: pointer; }
|
|
112
|
+
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
|
|
113
|
+
outline: 2px solid var(--focus); outline-offset: 2px;
|
|
114
|
+
}
|
|
115
|
+
code { font-family: var(--font-mono); background: var(--surface-2); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
|
|
116
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
117
|
+
|
|
118
|
+
/* ---------- topbar ---------- */
|
|
119
|
+
header#topbar {
|
|
120
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 16px;
|
|
121
|
+
padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--surface);
|
|
122
|
+
}
|
|
123
|
+
.brand { display: flex; align-items: center; gap: 9px; }
|
|
124
|
+
.brand .mark {
|
|
125
|
+
width: 26px; height: 26px; border-radius: 8px; background: var(--accent);
|
|
126
|
+
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; flex: none;
|
|
127
|
+
}
|
|
128
|
+
.brand h1 { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
|
|
129
|
+
#crumb { display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--text-dim); min-width: 0; overflow: hidden; }
|
|
130
|
+
#crumb b { color: var(--text); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
131
|
+
#crumb .sep { color: var(--text-faint); }
|
|
132
|
+
.toolbar { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
|
133
|
+
|
|
134
|
+
.btn {
|
|
135
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
136
|
+
background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
|
|
137
|
+
border-radius: 8px; padding: 7px 13px; font-size: 13px; font-weight: 600; white-space: nowrap;
|
|
138
|
+
}
|
|
139
|
+
.btn:hover { border-color: var(--text-faint); }
|
|
140
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
141
|
+
.btn.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
142
|
+
.btn.icon { padding: 7px 9px; }
|
|
143
|
+
.btn.ghost { background: transparent; border-color: transparent; }
|
|
144
|
+
.btn.ghost:hover { background: var(--surface-2); }
|
|
145
|
+
#file-input { display: none; }
|
|
146
|
+
.link-back {
|
|
147
|
+
background: none; border: none; color: var(--text-dim); font-size: 13.5px; font-weight: 600;
|
|
148
|
+
padding: 6px 2px; margin: 14px 0 4px;
|
|
149
|
+
}
|
|
150
|
+
.link-back:hover { color: var(--accent); }
|
|
151
|
+
|
|
152
|
+
/* ---------- screens ---------- */
|
|
153
|
+
.screen { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
154
|
+
main.screen { padding: 0 26px 40px; }
|
|
155
|
+
[hidden] { display: none !important; }
|
|
156
|
+
|
|
157
|
+
/* ---------- portfolio ---------- */
|
|
158
|
+
.portfolio-head { max-width: 1180px; margin: 26px auto 6px; }
|
|
159
|
+
.portfolio-head h2 { margin: 0 0 4px; font-size: 22px; }
|
|
160
|
+
.portfolio-head p { margin: 0; color: var(--text-dim); font-size: 13.5px; max-width: 66ch; }
|
|
161
|
+
.filter-row { max-width: 1180px; margin: 18px auto 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
162
|
+
.pill-toggle {
|
|
163
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
164
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
|
|
165
|
+
padding: 6px 13px; font-size: 12.5px; font-weight: 600; color: var(--text-dim);
|
|
166
|
+
}
|
|
167
|
+
.pill-toggle .dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
168
|
+
.pill-toggle.active { background: var(--text); border-color: var(--text); color: var(--bg); }
|
|
169
|
+
.pill-toggle.active .dot { background: var(--bg) !important; }
|
|
170
|
+
.search-inline {
|
|
171
|
+
margin-left: auto; display: flex; align-items: center; gap: 6px;
|
|
172
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
|
|
173
|
+
padding: 6px 12px; width: 210px;
|
|
174
|
+
}
|
|
175
|
+
.search-inline input { border: none; background: none; outline: none; font-size: 13px; width: 100%; }
|
|
176
|
+
.search-inline svg { opacity: .55; flex: none; }
|
|
177
|
+
|
|
178
|
+
.portfolio-grid {
|
|
179
|
+
max-width: 1180px; margin: 14px auto 0;
|
|
180
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;
|
|
181
|
+
}
|
|
182
|
+
.pitch-card {
|
|
183
|
+
display: flex; background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
|
184
|
+
overflow: hidden; text-align: left; box-shadow: 0 1px 2px rgba(20,25,35,.04);
|
|
185
|
+
padding: 0; margin: 0; appearance: none; -webkit-appearance: none; font: inherit;
|
|
186
|
+
}
|
|
187
|
+
.pitch-card:hover { border-color: var(--text-faint); }
|
|
188
|
+
.pitch-card .stripe { width: 5px; flex: none; }
|
|
189
|
+
.pitch-card .body { flex: 1 1 auto; padding: 16px 18px; min-width: 0; }
|
|
190
|
+
.pitch-card .top-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
|
191
|
+
.pitch-card h3 { margin: 0; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
192
|
+
.health-pill {
|
|
193
|
+
flex: none; font-size: 10.5px; font-weight: 700; letter-spacing: .02em; border-radius: 999px;
|
|
194
|
+
padding: 3px 9px; text-transform: uppercase;
|
|
195
|
+
}
|
|
196
|
+
.health-pill.on-track { background: var(--good-soft); color: var(--good); }
|
|
197
|
+
.health-pill.progress { background: var(--warn-soft); color: var(--warn); }
|
|
198
|
+
.health-pill.attention { background: var(--bad-soft); color: var(--bad); }
|
|
199
|
+
.health-pill.archived { background: var(--pending-soft); color: var(--pending); }
|
|
200
|
+
.phase-line { font-size: 12.5px; color: var(--text-dim); margin-top: 3px; }
|
|
201
|
+
.card-stats { display: flex; gap: 16px; margin-top: 10px; }
|
|
202
|
+
.card-stats div { font-size: 11.5px; color: var(--text-faint); }
|
|
203
|
+
.card-stats b { display: block; font-size: 15px; color: var(--text); font-weight: 700; }
|
|
204
|
+
.card-foot { margin-top: 10px; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
205
|
+
.card-hill { margin-top: 10px; }
|
|
206
|
+
.card-hill .hill-empty-note { font-size: 11px; color: var(--text-faint); padding: 4px 0; }
|
|
207
|
+
|
|
208
|
+
.empty-state {
|
|
209
|
+
max-width: 520px; margin: 60px auto; text-align: center; color: var(--text-dim);
|
|
210
|
+
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
|
211
|
+
}
|
|
212
|
+
.empty-state .glyph { font-size: 30px; }
|
|
213
|
+
.empty-state h3 { margin: 0; color: var(--text); }
|
|
214
|
+
.empty-state p { margin: 0; font-size: 13px; }
|
|
215
|
+
|
|
216
|
+
/* ---------- detail ---------- */
|
|
217
|
+
.detail-wrap { max-width: 980px; margin: 4px auto 0; }
|
|
218
|
+
.detail-hero {
|
|
219
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
|
|
220
|
+
padding: 22px 24px; display: flex; flex-direction: column; gap: 12px;
|
|
221
|
+
}
|
|
222
|
+
.detail-hero .hero-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
223
|
+
.detail-hero h2 { margin: 0; font-size: 24px; }
|
|
224
|
+
.meta-line { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-faint); display: flex; gap: 14px; flex-wrap: wrap; }
|
|
225
|
+
.meta-line b { color: var(--text-dim); font-weight: 500; }
|
|
226
|
+
.hero-stats { display: flex; gap: 26px; margin-top: 4px; flex-wrap: wrap; }
|
|
227
|
+
.hero-stats div { font-size: 12px; color: var(--text-faint); }
|
|
228
|
+
.hero-stats b { display: block; font-size: 21px; color: var(--text); font-weight: 800; }
|
|
229
|
+
|
|
230
|
+
.section { margin-top: 26px; }
|
|
231
|
+
.section h3 { margin: 0; font-size: 14.5px; }
|
|
232
|
+
.section .sub { margin: 2px 0 12px; font-size: 12px; color: var(--text-faint); }
|
|
233
|
+
.section-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 18px 20px; }
|
|
234
|
+
|
|
235
|
+
.archived-note {
|
|
236
|
+
display: flex; gap: 10px; align-items: flex-start; font-size: 13px;
|
|
237
|
+
background: var(--pending-soft); color: var(--text-dim); border-radius: 12px; padding: 14px 16px;
|
|
238
|
+
}
|
|
239
|
+
.archived-note b { color: var(--text); }
|
|
240
|
+
|
|
241
|
+
.attention-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
242
|
+
.attention-list li {
|
|
243
|
+
display: flex; gap: 9px; align-items: flex-start; font-size: 13px;
|
|
244
|
+
background: var(--bad-soft); color: var(--bad); border-radius: 10px; padding: 9px 12px;
|
|
245
|
+
}
|
|
246
|
+
.attention-list li.info { background: var(--warn-soft); color: var(--warn); }
|
|
247
|
+
.attention-list li span.icon { flex: none; }
|
|
248
|
+
|
|
249
|
+
/* ---------- hill chart ---------- */
|
|
250
|
+
.hill-wrap { display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
|
251
|
+
.hill-svg { width: 100%; max-width: 300px; height: auto; display: block; overflow: visible; }
|
|
252
|
+
.hill-wrap.hero { align-items: stretch; }
|
|
253
|
+
.hill-wrap.hero .hill-svg { max-width: 520px; margin: 0 auto; }
|
|
254
|
+
.hill-svg .ground { fill: var(--surface-2); }
|
|
255
|
+
.hill-svg .ridge { fill: none; stroke: var(--border); stroke-width: 1.2; }
|
|
256
|
+
.hill-svg .tick-line { stroke: var(--border-soft); stroke-width: .6; }
|
|
257
|
+
.hill-svg .tick-label { font-family: var(--font-mono); fill: var(--text-faint); font-size: 2.3px; }
|
|
258
|
+
.hill-svg .hill-dot { stroke: var(--surface); stroke-width: .6; cursor: pointer; }
|
|
259
|
+
.hill-svg .hill-dot.mini { cursor: default; }
|
|
260
|
+
.hill-svg .hill-count { font-family: var(--font-mono); font-size: 2.3px; fill: var(--surface); font-weight: 700; }
|
|
261
|
+
.hill-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-dim); }
|
|
262
|
+
.hill-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
|
263
|
+
.hill-legend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
264
|
+
.hill-empty-note { font-size: 12.5px; color: var(--text-faint); }
|
|
265
|
+
.hill-empty-note code { font-size: 11px; }
|
|
266
|
+
|
|
267
|
+
/* stepper (collapsed gate history) */
|
|
268
|
+
details.gate-history { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 4px 20px; }
|
|
269
|
+
details.gate-history summary {
|
|
270
|
+
padding: 14px 0; font-family: var(--font-display); font-size: 14.5px; font-weight: 700; cursor: pointer;
|
|
271
|
+
list-style: none;
|
|
272
|
+
}
|
|
273
|
+
details.gate-history summary::-webkit-details-marker { display: none; }
|
|
274
|
+
details.gate-history summary::after { content: '▸'; float: right; color: var(--text-faint); transition: transform .12s; }
|
|
275
|
+
details.gate-history[open] summary::after { transform: rotate(90deg); }
|
|
276
|
+
details.gate-history .sub { margin: -8px 0 12px; font-size: 12px; color: var(--text-faint); }
|
|
277
|
+
.stepper { display: flex; align-items: flex-start; overflow-x: auto; padding: 6px 4px 16px; }
|
|
278
|
+
.step { display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 92px; position: relative; flex: none; }
|
|
279
|
+
.step .connector { position: absolute; top: 14px; left: -50%; width: 100%; height: 2px; background: var(--border); z-index: 0; }
|
|
280
|
+
.step:first-child .connector { display: none; }
|
|
281
|
+
.step .node {
|
|
282
|
+
width: 29px; height: 29px; border-radius: 50%; z-index: 1;
|
|
283
|
+
display: flex; align-items: center; justify-content: center; font-size: 11.5px; font-weight: 800;
|
|
284
|
+
border: 2px solid var(--border); background: var(--surface); color: var(--text-faint);
|
|
285
|
+
}
|
|
286
|
+
.step.ok .node { background: var(--good); border-color: var(--good); color: #fff; }
|
|
287
|
+
.step.warn .node { background: var(--warn); border-color: var(--warn); color: #3a2600; }
|
|
288
|
+
.step.bad .node { background: var(--bad); border-color: var(--bad); color: #fff; }
|
|
289
|
+
.step.ok .connector { background: color-mix(in srgb, var(--good) 55%, var(--border)); }
|
|
290
|
+
.step.warn .connector { background: color-mix(in srgb, var(--warn) 55%, var(--border)); }
|
|
291
|
+
.step.bad .connector { background: color-mix(in srgb, var(--bad) 55%, var(--border)); }
|
|
292
|
+
.step .label { font-size: 11px; font-weight: 700; color: var(--text-dim); white-space: nowrap; }
|
|
293
|
+
.step .sub-label { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
294
|
+
.step button.hit { position: absolute; inset: -6px -20px; background: none; border: none; }
|
|
295
|
+
#gate-detail {
|
|
296
|
+
margin: -8px 0 12px; padding: 12px 14px; border-radius: 10px; background: var(--surface-2);
|
|
297
|
+
font-size: 12.5px; color: var(--text-dim); display: none;
|
|
298
|
+
}
|
|
299
|
+
#gate-detail.show { display: block; }
|
|
300
|
+
#gate-detail b { color: var(--text); }
|
|
301
|
+
|
|
302
|
+
/* scope board */
|
|
303
|
+
.board { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
304
|
+
.board-col { flex: 1 1 220px; min-width: 220px; }
|
|
305
|
+
.board-col-head { display: flex; align-items: center; gap: 7px; margin-bottom: 9px; }
|
|
306
|
+
.board-col-head .dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
307
|
+
.board-col-head span { font-size: 12px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .03em; }
|
|
308
|
+
.board-col-head b { font-family: var(--font-mono); color: var(--text-faint); font-weight: 500; }
|
|
309
|
+
.scope-card {
|
|
310
|
+
background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: 10px;
|
|
311
|
+
padding: 10px 12px; margin-bottom: 8px; transition: box-shadow .3s, background .3s;
|
|
312
|
+
}
|
|
313
|
+
.scope-card.flash { box-shadow: 0 0 0 2px var(--accent); background: var(--accent-soft); }
|
|
314
|
+
.scope-card .name { font-weight: 700; font-size: 13px; word-break: break-word; }
|
|
315
|
+
.scope-card .sub { font-size: 11.5px; color: var(--text-faint); margin-top: 3px; font-family: var(--font-mono); }
|
|
316
|
+
.board-empty { font-size: 12px; color: var(--text-faint); padding: 8px 2px; }
|
|
317
|
+
|
|
318
|
+
/* round matrix */
|
|
319
|
+
.matrix-scroll { overflow-x: auto; }
|
|
320
|
+
table.matrix { border-collapse: collapse; width: 100%; min-width: 420px; }
|
|
321
|
+
table.matrix th, table.matrix td { padding: 7px 10px; text-align: center; font-size: 12.5px; border-bottom: 1px solid var(--border-soft); }
|
|
322
|
+
table.matrix th.rowhead, table.matrix td.rowhead { text-align: left; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-dim); white-space: nowrap; }
|
|
323
|
+
table.matrix thead th { color: var(--text-faint); font-weight: 700; font-size: 11px; text-transform: uppercase; }
|
|
324
|
+
.verdict-chip { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 7px; font-size: 12px; font-weight: 800; }
|
|
325
|
+
.verdict-chip.green { background: var(--good-soft); color: var(--good); }
|
|
326
|
+
.verdict-chip.red { background: var(--bad-soft); color: var(--bad); }
|
|
327
|
+
.verdict-chip.na { color: var(--text-faint); }
|
|
328
|
+
|
|
329
|
+
.raw-launch { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
330
|
+
.raw-launch p { margin: 0; font-size: 12.5px; color: var(--text-faint); max-width: 48ch; }
|
|
331
|
+
|
|
332
|
+
/* ---------- raw graph screen ---------- */
|
|
333
|
+
.raw-layout { display: flex; height: calc(100vh - 128px); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; margin-top: 6px; }
|
|
334
|
+
.legend-rail { flex: 0 0 168px; border-right: 1px solid var(--border); background: var(--surface); overflow-y: auto; padding: 14px 12px; }
|
|
335
|
+
.legend-rail h2 { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); margin: 0 0 8px; font-weight: 700; }
|
|
336
|
+
.legend-group + .legend-group { margin-top: 16px; }
|
|
337
|
+
.chip { display: flex; align-items: center; gap: 7px; width: 100%; background: none; border: none; padding: 5px 6px; border-radius: 6px; font-size: 12px; text-align: left; color: var(--text); }
|
|
338
|
+
.chip:hover { background: var(--surface-2); }
|
|
339
|
+
.chip .swatch { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
|
340
|
+
.chip .label { flex: 1 1 auto; }
|
|
341
|
+
.chip .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-faint); min-width: 1.4em; text-align: right; }
|
|
342
|
+
.chip.off { opacity: .4; }
|
|
343
|
+
.canvas-wrap { flex: 1 1 auto; position: relative; background: radial-gradient(var(--canvas-dot) 1px, transparent 1.4px) 0 0/22px 22px, var(--canvas); min-width: 0; }
|
|
344
|
+
svg#graph-svg { width: 100%; height: 100%; display: block; cursor: grab; }
|
|
345
|
+
svg#graph-svg.panning { cursor: grabbing; }
|
|
346
|
+
.node-g { cursor: pointer; }
|
|
347
|
+
.node-g circle.halo { fill: none; stroke: var(--accent); stroke-width: 2.5px; opacity: 0; }
|
|
348
|
+
.node-g.selected circle.halo { opacity: 1; }
|
|
349
|
+
.node-g.hovered circle.node-dot { filter: brightness(1.15); }
|
|
350
|
+
.node-g text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--text-dim); pointer-events: none; }
|
|
351
|
+
.node-g.dim { opacity: .16; }
|
|
352
|
+
.node-g.dim text { display: none; }
|
|
353
|
+
.node-g circle.status-ring { fill: none; stroke-width: 2px; }
|
|
354
|
+
line.edge { stroke: var(--text-faint); stroke-width: 1.1px; opacity: .55; }
|
|
355
|
+
line.edge.active { stroke: var(--accent); stroke-width: 1.8px; opacity: .95; }
|
|
356
|
+
line.edge.dim { opacity: .08; }
|
|
357
|
+
line.edge.t-SUPERSEDES { stroke-dasharray: 1 3; stroke-linecap: round; }
|
|
358
|
+
line.edge.t-DEPENDS_ON { stroke-dasharray: 5 3; }
|
|
359
|
+
line.edge.t-COVERS, line.edge.t-IMPLEMENTS { stroke-dasharray: 2 2; }
|
|
360
|
+
.zoom-controls { position: absolute; right: 14px; bottom: 14px; display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,.12); }
|
|
361
|
+
.zoom-controls button { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: var(--surface); border: none; color: var(--text); font-size: 15px; }
|
|
362
|
+
.zoom-controls button + button { border-top: 1px solid var(--border); }
|
|
363
|
+
.zoom-controls button:hover { background: var(--surface-2); }
|
|
364
|
+
.inspector { flex: 0 0 290px; border-left: 1px solid var(--border); background: var(--surface); overflow-y: auto; padding: 14px; }
|
|
365
|
+
.inspector h2 { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); margin: 0 0 8px; font-weight: 700; }
|
|
366
|
+
.insp-empty { color: var(--text-dim); font-size: 12px; line-height: 1.6; }
|
|
367
|
+
.insp-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
368
|
+
.insp-head .swatch { width: 12px; height: 12px; border-radius: 50%; flex: none; }
|
|
369
|
+
.insp-head .type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); font-weight: 700; }
|
|
370
|
+
.insp-id { font-family: var(--font-mono); font-size: 12px; word-break: break-all; margin: 4px 0 12px; }
|
|
371
|
+
.kv { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
|
|
372
|
+
.kv tr + tr td, .kv tr + tr th { border-top: 1px solid var(--border-soft); }
|
|
373
|
+
.kv th, .kv td { text-align: left; padding: 5px 0; font-size: 11.5px; vertical-align: top; }
|
|
374
|
+
.kv th { color: var(--text-faint); font-weight: 500; width: 38%; }
|
|
375
|
+
.kv td { font-family: var(--font-mono); font-size: 11px; word-break: break-word; }
|
|
376
|
+
.conn-group h4 { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); margin: 12px 0 6px; font-weight: 700; }
|
|
377
|
+
.conn-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; font-size: 11.5px; cursor: pointer; }
|
|
378
|
+
.conn-row:hover { background: var(--surface-2); }
|
|
379
|
+
.conn-row .swatch { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
380
|
+
.conn-row .edge-t { font-family: var(--font-mono); font-size: 9.5px; color: var(--text-faint); flex: none; }
|
|
381
|
+
.conn-row .id { font-family: var(--font-mono); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
382
|
+
.empty-hint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; color: var(--text-faint); font-size: 13px; text-align: center; padding: 20px; }
|
|
383
|
+
.empty-hint.show { display: flex; }
|
|
384
|
+
|
|
385
|
+
/* overlay */
|
|
386
|
+
.overlay { position: fixed; inset: 0; background: rgba(10,14,20,.5); display: none; align-items: center; justify-content: center; z-index: 20; }
|
|
387
|
+
.overlay.show { display: flex; }
|
|
388
|
+
.overlay-card { width: min(680px, 92vw); background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px; box-shadow: 0 16px 44px rgba(0,0,0,.24); }
|
|
389
|
+
.overlay-card h2 { margin: 0 0 4px; font-size: 15px; }
|
|
390
|
+
.overlay-card p { margin: 0 0 12px; font-size: 12px; color: var(--text-dim); }
|
|
391
|
+
.overlay-card input[type=text] { width: 100%; margin-bottom: 10px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; font-size: 12.5px; }
|
|
392
|
+
.overlay-card textarea { width: 100%; height: 200px; resize: vertical; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-family: var(--font-mono); font-size: 11.5px; }
|
|
393
|
+
.overlay-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
|
394
|
+
|
|
395
|
+
@media (max-width: 720px) {
|
|
396
|
+
.raw-layout { flex-direction: column; height: auto; }
|
|
397
|
+
.legend-rail, .inspector { flex: none; width: 100%; max-height: 220px; }
|
|
398
|
+
.canvas-wrap { height: 60vh; }
|
|
399
|
+
}
|
|
400
|
+
</style>
|
|
401
|
+
|
|
402
|
+
<header id="topbar">
|
|
403
|
+
<div class="brand"><span class="mark">▲</span><h1>Hill Chart</h1></div>
|
|
404
|
+
<div id="crumb"></div>
|
|
405
|
+
<div class="toolbar">
|
|
406
|
+
<button class="btn" id="btn-paste">Paste one</button>
|
|
407
|
+
<label class="btn primary" for="file-input">Load .jsonl files</label>
|
|
408
|
+
<input id="file-input" type="file" accept=".jsonl,.json,.txt,text/plain" multiple>
|
|
409
|
+
</div>
|
|
410
|
+
</header>
|
|
411
|
+
|
|
412
|
+
<main id="screen-portfolio" class="screen">
|
|
413
|
+
<div class="portfolio-head">
|
|
414
|
+
<h2>Your pitches, at a glance</h2>
|
|
415
|
+
<p>One card per pitch that has reached kickoff — projected from its committed hill shards
|
|
416
|
+
(<code>shapeup/<slug>/hill/*.yml</code>) and, while the local run trace survives, its run graph
|
|
417
|
+
(<code>.shapeup/<slug>/graph.jsonl</code>). A pitch still in Shaping or at the Betting Table has no
|
|
418
|
+
run yet, so it won't appear here. One whose local trace was cleaned up after shipping still
|
|
419
|
+
appears — marked Archived — from its hill shards alone.</p>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="filter-row" id="filter-row">
|
|
422
|
+
<div id="health-chips" style="display:contents"></div>
|
|
423
|
+
<div class="search-inline">
|
|
424
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
|
|
425
|
+
<input type="text" id="portfolio-search-input" placeholder="Find a pitch…" autocomplete="off" spellcheck="false">
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div class="portfolio-grid" id="portfolio-grid"></div>
|
|
429
|
+
<div class="empty-state" id="portfolio-empty">
|
|
430
|
+
<div class="glyph">⛰️</div>
|
|
431
|
+
<h3>No pitches yet</h3>
|
|
432
|
+
<p>Run <code>/shapeup</code> to shape one, or load a <code>graph.jsonl</code> to explore another
|
|
433
|
+
project's pitch.</p>
|
|
434
|
+
</div>
|
|
435
|
+
</main>
|
|
436
|
+
|
|
437
|
+
<main id="screen-detail" class="screen" hidden>
|
|
438
|
+
<button class="link-back" id="back-to-portfolio">← All pitches</button>
|
|
439
|
+
<div class="detail-wrap" id="detail-body"></div>
|
|
440
|
+
</main>
|
|
441
|
+
|
|
442
|
+
<main id="screen-raw" class="screen" hidden>
|
|
443
|
+
<button class="link-back" id="back-to-detail">← Back to pitch</button>
|
|
444
|
+
<div class="raw-layout">
|
|
445
|
+
<nav class="legend-rail" id="legend-rail" aria-label="Node type filters"></nav>
|
|
446
|
+
<div class="canvas-wrap" id="canvas-wrap">
|
|
447
|
+
<svg id="graph-svg">
|
|
448
|
+
<defs>
|
|
449
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
450
|
+
<path d="M0,0 L10,5 L0,10 z" fill="var(--text-faint)"></path>
|
|
451
|
+
</marker>
|
|
452
|
+
</defs>
|
|
453
|
+
<g id="viewport">
|
|
454
|
+
<g id="edges-layer"></g>
|
|
455
|
+
<g id="nodes-layer"></g>
|
|
456
|
+
</g>
|
|
457
|
+
</svg>
|
|
458
|
+
<div class="zoom-controls">
|
|
459
|
+
<button id="zoom-in" aria-label="Zoom in">+</button>
|
|
460
|
+
<button id="zoom-out" aria-label="Zoom out">–</button>
|
|
461
|
+
<button id="zoom-reset" aria-label="Reset zoom">⦿</button>
|
|
462
|
+
</div>
|
|
463
|
+
<div class="empty-hint" id="empty-hint"><div>No nodes to show — clear a filter.</div></div>
|
|
464
|
+
</div>
|
|
465
|
+
<aside class="inspector" id="inspector">
|
|
466
|
+
<h2>Inspector</h2>
|
|
467
|
+
<div id="inspector-body" class="insp-empty">Click a node or edge to inspect it.</div>
|
|
468
|
+
</aside>
|
|
469
|
+
</div>
|
|
470
|
+
</main>
|
|
471
|
+
|
|
472
|
+
<div class="overlay" id="overlay">
|
|
473
|
+
<div class="overlay-card">
|
|
474
|
+
<h2>Paste a pitch's graph.jsonl</h2>
|
|
475
|
+
<p>One JSON object per line. The slug is read from the file's <code>Run</code> node when present —
|
|
476
|
+
override it below if you'd like. A pasted file carries no hill data (that lives in a separate
|
|
477
|
+
committed shard), so its Hill Chart shows as not yet available.</p>
|
|
478
|
+
<input type="text" id="paste-label" placeholder="Pitch name (optional — inferred from the file otherwise)">
|
|
479
|
+
<textarea id="paste-area" placeholder='{"k":"node","id":"run:…","t":"Run",…}'></textarea>
|
|
480
|
+
<div class="overlay-actions">
|
|
481
|
+
<button class="btn" id="paste-cancel">Cancel</button>
|
|
482
|
+
<button class="btn primary" id="paste-apply">Add pitch</button>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<script>
|
|
488
|
+
(function () {
|
|
489
|
+
'use strict';
|
|
490
|
+
|
|
491
|
+
// ============================================================================================
|
|
492
|
+
// Shared graph model — mirrors kernel/reduce/graph.mjs's WORK_NODES / DOMAIN_NODES / readGraph.
|
|
493
|
+
// ============================================================================================
|
|
494
|
+
var NODE_META = {
|
|
495
|
+
Run: { family: 'work', color: 'var(--n-run)' }, Order: { family: 'work', color: 'var(--n-order)' },
|
|
496
|
+
Result: { family: 'work', color: 'var(--n-result)' }, Verdict: { family: 'work', color: 'var(--n-verdict)' },
|
|
497
|
+
Trial: { family: 'work', color: 'var(--n-trial)' }, GateDecision: { family: 'work', color: 'var(--n-gate)' },
|
|
498
|
+
Scope: { family: 'domain', color: 'var(--n-scope)' }, UseCase: { family: 'domain', color: 'var(--n-usecase)' },
|
|
499
|
+
Requirement: { family: 'domain', color: 'var(--n-requirement)' }, Seam: { family: 'domain', color: 'var(--n-seam)' }
|
|
500
|
+
};
|
|
501
|
+
var TYPE_ORDER = ['Run', 'Order', 'Result', 'Verdict', 'Trial', 'GateDecision', 'Scope', 'UseCase', 'Requirement', 'Seam'];
|
|
502
|
+
function metaFor(t) { return NODE_META[t] || { family: 'other', color: 'var(--n-other)' }; }
|
|
503
|
+
|
|
504
|
+
function parseGraph(text) {
|
|
505
|
+
var nodes = new Map(), edges = new Map(), lines = 0, malformed = 0;
|
|
506
|
+
String(text || '').split('\n').forEach(function (raw) {
|
|
507
|
+
var line = raw.trim();
|
|
508
|
+
if (!line) return;
|
|
509
|
+
var row;
|
|
510
|
+
try { row = JSON.parse(line); } catch (e) { malformed++; return; }
|
|
511
|
+
lines++;
|
|
512
|
+
if (row && row.k === 'node' && row.id) {
|
|
513
|
+
var prev = nodes.get(row.id) || {}, merged = {};
|
|
514
|
+
for (var k1 in prev) merged[k1] = prev[k1];
|
|
515
|
+
for (var k2 in row) merged[k2] = row[k2];
|
|
516
|
+
nodes.set(row.id, merged);
|
|
517
|
+
} else if (row && row.k === 'edge' && row.from && row.to && row.t) {
|
|
518
|
+
edges.set(row.from + '|' + row.t + '|' + row.to, row);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
return { nodes: nodes, edges: edges, lines: lines, malformed: malformed };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function buildModel(parsed) {
|
|
525
|
+
var nodeList = [], byId = new Map(), idx = 0;
|
|
526
|
+
parsed.nodes.forEach(function (raw, id) {
|
|
527
|
+
var t = raw.t || 'Unknown', meta = metaFor(t);
|
|
528
|
+
var n = { id: id, t: t, family: meta.family, attrs: raw, x: 0, y: 0, vx: 0, vy: 0, pinned: false, degree: 0, seedIndex: idx++ };
|
|
529
|
+
nodeList.push(n); byId.set(id, n);
|
|
530
|
+
});
|
|
531
|
+
var linkList = [], dangling = 0;
|
|
532
|
+
parsed.edges.forEach(function (raw, key) {
|
|
533
|
+
var from = byId.get(raw.from), to = byId.get(raw.to);
|
|
534
|
+
if (!from || !to) { dangling++; return; }
|
|
535
|
+
from.degree++; to.degree++;
|
|
536
|
+
linkList.push({ from: from, to: to, t: raw.t, key: key });
|
|
537
|
+
});
|
|
538
|
+
return { nodeList: nodeList, linkList: linkList, byId: byId, dangling: dangling };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function fmtVal(v) {
|
|
542
|
+
if (v === null || v === undefined) return '—';
|
|
543
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
544
|
+
return String(v);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function slugFor(parsed, fallback) {
|
|
548
|
+
var run = null;
|
|
549
|
+
parsed.nodes.forEach(function (n) { if (!run && n.t === 'Run' && n.slug) run = n; });
|
|
550
|
+
if (run) return run.slug;
|
|
551
|
+
var scopeMatch = null;
|
|
552
|
+
parsed.nodes.forEach(function (n) {
|
|
553
|
+
if (!scopeMatch && typeof n.id === 'string') {
|
|
554
|
+
var m = /^scope:([^:]+):/.exec(n.id);
|
|
555
|
+
if (m) scopeMatch = m[1];
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
return scopeMatch || fallback || ('pitch-' + Math.floor(1 + Math.random() * 900));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ============================================================================================
|
|
562
|
+
// Embedded data — filled in by the hill-chart skill's authoring step. Left as the literal
|
|
563
|
+
// placeholder, this stays a valid (if inert) JS string; the portfolio then boots empty, same as
|
|
564
|
+
// a project with no pitches yet. See SKILL.md's "Injection contract" for the exact replacement.
|
|
565
|
+
// ============================================================================================
|
|
566
|
+
var EMBEDDED_DATA_JSON = "__HILL_CHART_EMBEDDED_DATA__";
|
|
567
|
+
|
|
568
|
+
// ============================================================================================
|
|
569
|
+
// Hill Chart geometry — ONE function decides where a phase sits on the curve, and the same
|
|
570
|
+
// function draws the curve. The phase is a 4-value discrete enum (kernel/reduce/hill.mjs), never
|
|
571
|
+
// a continuous position — nothing here may interpolate between stations or invent a "62% up
|
|
572
|
+
// the hill." Every dot snaps to exactly one of four fixed x positions.
|
|
573
|
+
// ============================================================================================
|
|
574
|
+
var HILL_STATIONS = {
|
|
575
|
+
UPHILL_UNKNOWN: { x: 12, label: 'Unknown', color: 'var(--hill-uphill-unknown)' },
|
|
576
|
+
UPHILL_SOLVED: { x: 40, label: 'Solved', color: 'var(--hill-uphill-solved)' },
|
|
577
|
+
DOWNHILL_EXECUTION: { x: 62, label: 'Executing', color: 'var(--hill-downhill-execution)' },
|
|
578
|
+
FINISHED: { x: 92, label: 'Finished', color: 'var(--hill-finished)' }
|
|
579
|
+
};
|
|
580
|
+
var HILL_ORDER = ['UPHILL_UNKNOWN', 'UPHILL_SOLVED', 'DOWNHILL_EXECUTION', 'FINISHED'];
|
|
581
|
+
|
|
582
|
+
function hillY(x) { return 36 - 32 * Math.sin(Math.PI * Math.max(0, Math.min(100, x)) / 100); }
|
|
583
|
+
function hillStation(phase) {
|
|
584
|
+
var s = HILL_STATIONS[phase];
|
|
585
|
+
if (!s) return null;
|
|
586
|
+
return { x: s.x, y: hillY(s.x) };
|
|
587
|
+
}
|
|
588
|
+
function hillPathD() {
|
|
589
|
+
var pts = [], step = 4;
|
|
590
|
+
for (var x = 0; x <= 100; x += step) pts.push([x, hillY(x)]);
|
|
591
|
+
if (pts[pts.length - 1][0] !== 100) pts.push([100, hillY(100)]);
|
|
592
|
+
var d = 'M' + pts[0][0] + ',' + pts[0][1].toFixed(2);
|
|
593
|
+
for (var i = 1; i < pts.length; i++) d += ' L' + pts[i][0] + ',' + pts[i][1].toFixed(2);
|
|
594
|
+
return d;
|
|
595
|
+
}
|
|
596
|
+
var HILL_GROUND_D = hillPathD() + ' L100,40 L0,40 Z';
|
|
597
|
+
var HILL_RIDGE_D = hillPathD();
|
|
598
|
+
|
|
599
|
+
var STATUS_COLOR = { green: 'var(--good)', red: 'var(--bad)', building: 'var(--pending)', queued: 'var(--pending)' };
|
|
600
|
+
function dotColorFor(status) { return STATUS_COLOR[status] || 'var(--pending)'; }
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Render one Hill Chart. `mode` is 'mini' (portfolio card, no interaction, small stack cap) or
|
|
604
|
+
* 'hero' (pitch detail, clickable dots, larger stack cap). Both paths call `hillStation()` for
|
|
605
|
+
* placement — a mini chart is a literal miniature of the hero one, not a different rendering.
|
|
606
|
+
*/
|
|
607
|
+
function renderHillSVG(scopeStatusList, mode) {
|
|
608
|
+
var cap = mode === 'hero' ? 8 : 4;
|
|
609
|
+
var byStation = {};
|
|
610
|
+
HILL_ORDER.forEach(function (p) { byStation[p] = []; });
|
|
611
|
+
scopeStatusList.forEach(function (s) {
|
|
612
|
+
var p = HILL_STATIONS[s.hillPhase] ? s.hillPhase : 'UPHILL_UNKNOWN';
|
|
613
|
+
byStation[p].push(s);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
var svg = '<svg class="hill-svg" viewBox="0 0 100 40" role="img" aria-label="Hill chart">';
|
|
617
|
+
svg += '<path class="ground" d="' + HILL_GROUND_D + '"></path>';
|
|
618
|
+
svg += '<path class="ridge" d="' + HILL_RIDGE_D + '"></path>';
|
|
619
|
+
if (mode === 'hero') {
|
|
620
|
+
// Labels always sit on one fixed baseline, never near the curve itself — a station near
|
|
621
|
+
// the crest stacks its dots upward from the ridge, and a label placed by curve height would
|
|
622
|
+
// collide with exactly the dots it is meant to identify.
|
|
623
|
+
HILL_ORDER.forEach(function (p) {
|
|
624
|
+
var st = HILL_STATIONS[p];
|
|
625
|
+
svg += '<line class="tick-line" x1="' + st.x + '" y1="' + hillY(st.x) + '" x2="' + st.x + '" y2="39"></line>';
|
|
626
|
+
svg += '<text class="tick-label" x="' + st.x + '" y="38.5" text-anchor="middle">' + st.label + '</text>';
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
// Radius and font-size are the SAME numbers in both modes — the mini card and the hero view
|
|
630
|
+
// are the same drawing at a different final CSS size (controlled by `.hill-svg` max-width),
|
|
631
|
+
// never two differently-tuned charts. Only the hero view draws tick labels/lines and accepts
|
|
632
|
+
// clicks (`data-scope`) — a mini card is a glance, not a control.
|
|
633
|
+
HILL_ORDER.forEach(function (p) {
|
|
634
|
+
var items = byStation[p];
|
|
635
|
+
if (!items.length) return;
|
|
636
|
+
var shown = items.slice(0, cap);
|
|
637
|
+
var extra = items.length - shown.length;
|
|
638
|
+
var st = HILL_STATIONS[p];
|
|
639
|
+
shown.forEach(function (s, i) {
|
|
640
|
+
var dy = hillY(st.x) - 4 - i * 4.2;
|
|
641
|
+
var color = mode === 'hero' ? dotColorFor(s.status) : st.color;
|
|
642
|
+
var titleText = s.scopeId + ' — ' + st.label + (mode === 'hero' && s.status ? ' (' + s.status + ')' : '');
|
|
643
|
+
svg += '<circle class="hill-dot' + (mode === 'mini' ? ' mini' : '') + '"' +
|
|
644
|
+
(mode === 'hero' ? ' data-scope="' + String(s.scopeId).replace(/"/g, '"') + '"' : '') +
|
|
645
|
+
' cx="' + st.x + '" cy="' + dy + '" r="2.1" fill="' + color + '"><title>' + titleText + '</title></circle>';
|
|
646
|
+
});
|
|
647
|
+
if (extra > 0) {
|
|
648
|
+
var dy2 = hillY(st.x) - 4 - shown.length * 4.2;
|
|
649
|
+
svg += '<circle class="hill-dot' + (mode === 'mini' ? ' mini' : '') + '" cx="' + st.x + '" cy="' + dy2 + '" r="2.6" fill="' + st.color + '"><title>+' + extra + ' more at ' + st.label + '</title></circle>';
|
|
650
|
+
svg += '<text class="hill-count" x="' + st.x + '" y="' + (dy2 + 1) + '" text-anchor="middle">+' + extra + '</text>';
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
svg += '</svg>';
|
|
654
|
+
return svg;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ============================================================================================
|
|
658
|
+
// Pitch model — one per discovered pitch. Scope identity is the UNION of what the run graph
|
|
659
|
+
// knows (has orders/verdicts — local tier) and what the committed hill shards know (survives a
|
|
660
|
+
// cleaned-up local trace) — a scope can be present in only one of the two.
|
|
661
|
+
// ============================================================================================
|
|
662
|
+
function buildPitch(entry) {
|
|
663
|
+
var hasLocal = !!entry.hasLocal;
|
|
664
|
+
var hasCommitted = !!entry.hasCommitted;
|
|
665
|
+
var hillList = Array.isArray(entry.hill) ? entry.hill : [];
|
|
666
|
+
var hillMap = new Map();
|
|
667
|
+
hillList.forEach(function (h) { if (h && h.scope_id) hillMap.set(h.scope_id, h.phase); });
|
|
668
|
+
var hillAvailable = hillMap.size > 0;
|
|
669
|
+
|
|
670
|
+
var parsed = parseGraph(hasLocal ? (entry.graphJsonl || '') : '');
|
|
671
|
+
var model = buildModel(parsed);
|
|
672
|
+
var run = model.nodeList.find(function (n) { return n.t === 'Run'; }) || null;
|
|
673
|
+
var graphScopes = model.nodeList.filter(function (n) { return n.t === 'Scope'; });
|
|
674
|
+
var verdicts = model.nodeList.filter(function (n) { return n.t === 'Verdict'; });
|
|
675
|
+
var gates = model.nodeList.filter(function (n) { return n.t === 'GateDecision'; });
|
|
676
|
+
var orders = model.nodeList.filter(function (n) { return n.t === 'Order'; });
|
|
677
|
+
|
|
678
|
+
function verdictsFor(scopeId) {
|
|
679
|
+
return verdicts.filter(function (v) { return v.attrs.scope_id === scopeId; })
|
|
680
|
+
.sort(function (a, b) { return (a.attrs.round - b.attrs.round) || (a.attrs.attempt - b.attrs.attempt); });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
var scopeIds = [], seenIds = new Set();
|
|
684
|
+
graphScopes.forEach(function (s) { if (!seenIds.has(s.attrs.scope_id)) { seenIds.add(s.attrs.scope_id); scopeIds.push(s.attrs.scope_id); } });
|
|
685
|
+
hillList.forEach(function (h) { if (h.scope_id && !seenIds.has(h.scope_id)) { seenIds.add(h.scope_id); scopeIds.push(h.scope_id); } });
|
|
686
|
+
|
|
687
|
+
var scopeStatus = scopeIds.map(function (scopeId) {
|
|
688
|
+
var vs = verdictsFor(scopeId);
|
|
689
|
+
var latest = vs.length ? vs[vs.length - 1] : null;
|
|
690
|
+
var hasOrder = orders.some(function (o) { return o.attrs.scope_id === scopeId; });
|
|
691
|
+
var status = !hasLocal ? null : (!hasOrder ? 'queued' : (!latest ? 'building' : (latest.attrs.overall === 'green' ? 'green' : 'red')));
|
|
692
|
+
// A scope the graph knows but the hill shards don't yet (freshly cut, hill not derived for
|
|
693
|
+
// it this round) reads as UPHILL_UNKNOWN when this pitch has ANY hill data at all — that is
|
|
694
|
+
// exactly deriveHill()'s own starting default before any evidence promotes it. A pitch with
|
|
695
|
+
// no hill data at all (e.g. a manually-loaded graph.jsonl with no shard file) gets no
|
|
696
|
+
// fabricated phase — see hillAvailable below.
|
|
697
|
+
var hillPhase = hillMap.has(scopeId) ? hillMap.get(scopeId) : (hillAvailable ? 'UPHILL_UNKNOWN' : null);
|
|
698
|
+
return { scopeId: scopeId, latest: latest, attempts: vs.length, status: status, hillPhase: hillPhase };
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
var roundSet = {};
|
|
702
|
+
verdicts.forEach(function (v) { if (v.attrs.round != null) roundSet[v.attrs.round] = true; });
|
|
703
|
+
gates.forEach(function (g) { if (g.attrs.round != null) roundSet[g.attrs.round] = true; });
|
|
704
|
+
var rounds = Object.keys(roundSet).map(Number).sort(function (a, b) { return a - b; });
|
|
705
|
+
|
|
706
|
+
var gateNames = gates.map(function (g) { return g.attrs.gate; });
|
|
707
|
+
var builtScopes = scopeStatus.filter(function (s) { return s.status != null; });
|
|
708
|
+
var allGreen = builtScopes.length > 0 && builtScopes.every(function (s) { return s.status === 'green'; });
|
|
709
|
+
var phase = !hasLocal ? 'Archived'
|
|
710
|
+
: gateNames.indexOf('L4') !== -1 ? 'Shipped'
|
|
711
|
+
: (verdicts.length && allGreen) ? 'Building · Round complete'
|
|
712
|
+
: verdicts.length ? 'Building · Verify'
|
|
713
|
+
: gateNames.indexOf('L1b') !== -1 ? 'Building · Scoped'
|
|
714
|
+
: gateNames.indexOf('L1a.5') !== -1 ? 'Building · Wired'
|
|
715
|
+
: gateNames.indexOf('L1a') !== -1 ? 'Building · Oriented'
|
|
716
|
+
: 'Building · Kickoff';
|
|
717
|
+
|
|
718
|
+
var anyRed = scopeStatus.some(function (s) { return s.status === 'red'; });
|
|
719
|
+
var anyInFlight = scopeStatus.some(function (s) { return s.status === 'building' || s.status === 'queued'; });
|
|
720
|
+
var anyStuckUnknown = hasLocal && scopeStatus.some(function (s) { return s.status != null && s.hillPhase === 'UPHILL_UNKNOWN'; });
|
|
721
|
+
var health = !hasLocal ? 'archived'
|
|
722
|
+
: (anyRed || model.dangling > 0 || anyStuckUnknown) ? 'attention'
|
|
723
|
+
: anyInFlight ? 'progress'
|
|
724
|
+
: (builtScopes.length && allGreen) ? 'on-track' : 'progress';
|
|
725
|
+
|
|
726
|
+
var greenCount = scopeStatus.filter(function (s) { return s.status === 'green'; }).length;
|
|
727
|
+
var finishedCount = scopeStatus.filter(function (s) { return s.hillPhase === 'FINISHED'; }).length;
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
slug: entry.slug, hasLocal: hasLocal, hasCommitted: hasCommitted, hillAvailable: hillAvailable,
|
|
731
|
+
model: model, run: run, scopeStatus: scopeStatus,
|
|
732
|
+
verdicts: verdicts, gates: gates, orders: orders,
|
|
733
|
+
rounds: rounds, phase: phase, health: health,
|
|
734
|
+
greenCount: greenCount, finishedCount: finishedCount, totalScopes: scopeStatus.length,
|
|
735
|
+
parsedMeta: { lines: parsed.lines, malformed: parsed.malformed }
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ============================================================================================
|
|
740
|
+
// App state
|
|
741
|
+
// ============================================================================================
|
|
742
|
+
var pitches = new Map(); // slug -> pitch
|
|
743
|
+
var currentSlug = null;
|
|
744
|
+
var healthFilter = 'all';
|
|
745
|
+
var portfolioSearch = '';
|
|
746
|
+
|
|
747
|
+
var screenPortfolio = document.getElementById('screen-portfolio');
|
|
748
|
+
var screenDetail = document.getElementById('screen-detail');
|
|
749
|
+
var screenRaw = document.getElementById('screen-raw');
|
|
750
|
+
var crumb = document.getElementById('crumb');
|
|
751
|
+
var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
752
|
+
|
|
753
|
+
function showScreen(name) {
|
|
754
|
+
screenPortfolio.hidden = name !== 'portfolio';
|
|
755
|
+
screenDetail.hidden = name !== 'detail';
|
|
756
|
+
screenRaw.hidden = name !== 'raw';
|
|
757
|
+
window.scrollTo(0, 0);
|
|
758
|
+
renderCrumb(name);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function renderCrumb(name) {
|
|
762
|
+
if (name === 'portfolio' || !currentSlug) { crumb.innerHTML = ''; return; }
|
|
763
|
+
var p = pitches.get(currentSlug);
|
|
764
|
+
var label = p ? p.slug : currentSlug;
|
|
765
|
+
crumb.innerHTML = '<span class="sep">/</span><b>' + label + '</b>' + (name === 'raw' ? '<span class="sep">/</span><b>raw graph</b>' : '');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function addPitch(entry) {
|
|
769
|
+
var pitch = buildPitch(entry);
|
|
770
|
+
pitches.set(pitch.slug, pitch);
|
|
771
|
+
return pitch;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function loadEmbedded() {
|
|
775
|
+
var entries = [];
|
|
776
|
+
if (EMBEDDED_DATA_JSON !== '__HILL_CHART_EMBEDDED_DATA__') {
|
|
777
|
+
try {
|
|
778
|
+
var parsed = JSON.parse(EMBEDDED_DATA_JSON);
|
|
779
|
+
if (Array.isArray(parsed)) entries = parsed;
|
|
780
|
+
} catch (e) { entries = []; }
|
|
781
|
+
}
|
|
782
|
+
pitches.clear();
|
|
783
|
+
entries.forEach(function (e) { if (e && e.slug) addPitch(e); });
|
|
784
|
+
renderPortfolio();
|
|
785
|
+
showScreen('portfolio');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ============================================================================================
|
|
789
|
+
// Portfolio screen
|
|
790
|
+
// ============================================================================================
|
|
791
|
+
var HEALTH_LABEL = { 'on-track': 'On track', progress: 'In progress', attention: 'Needs attention', archived: 'Archived' };
|
|
792
|
+
var HEALTH_COLOR_VAR = { 'on-track': '--good', progress: '--warn', attention: '--bad', archived: '--pending' };
|
|
793
|
+
|
|
794
|
+
function renderFilterRow() {
|
|
795
|
+
var row = document.getElementById('health-chips');
|
|
796
|
+
var counts = { all: pitches.size, 'on-track': 0, progress: 0, attention: 0, archived: 0 };
|
|
797
|
+
pitches.forEach(function (p) { counts[p.health]++; });
|
|
798
|
+
var defs = [['all', 'All'], ['on-track', 'On track'], ['progress', 'In progress'], ['attention', 'Needs attention'], ['archived', 'Archived']];
|
|
799
|
+
row.innerHTML = '';
|
|
800
|
+
defs.forEach(function (d) {
|
|
801
|
+
var btn = document.createElement('button');
|
|
802
|
+
btn.className = 'pill-toggle' + (healthFilter === d[0] ? ' active' : '');
|
|
803
|
+
var dotColor = d[0] === 'all' ? 'var(--text-faint)' : 'var(' + HEALTH_COLOR_VAR[d[0]] + ')';
|
|
804
|
+
btn.innerHTML = '<span class="dot" style="background:' + dotColor + '"></span>' + d[1] + ' <span class="num">(' + counts[d[0]] + ')</span>';
|
|
805
|
+
btn.addEventListener('click', function () { healthFilter = d[0]; renderPortfolio(); });
|
|
806
|
+
row.appendChild(btn);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
var searchInputEl = document.getElementById('portfolio-search-input');
|
|
811
|
+
searchInputEl.addEventListener('input', function (ev) { portfolioSearch = ev.target.value; renderGrid(); });
|
|
812
|
+
|
|
813
|
+
function renderPortfolio() { renderFilterRow(); renderGrid(); }
|
|
814
|
+
|
|
815
|
+
function renderGrid() {
|
|
816
|
+
var grid = document.getElementById('portfolio-grid');
|
|
817
|
+
var emptyState = document.getElementById('portfolio-empty');
|
|
818
|
+
grid.innerHTML = '';
|
|
819
|
+
var term = portfolioSearch.trim().toLowerCase();
|
|
820
|
+
var list = Array.from(pitches.values()).filter(function (p) {
|
|
821
|
+
if (healthFilter !== 'all' && p.health !== healthFilter) return false;
|
|
822
|
+
if (term && p.slug.toLowerCase().indexOf(term) === -1 && p.phase.toLowerCase().indexOf(term) === -1) return false;
|
|
823
|
+
return true;
|
|
824
|
+
});
|
|
825
|
+
emptyState.style.display = pitches.size === 0 ? 'flex' : 'none';
|
|
826
|
+
if (pitches.size > 0 && list.length === 0) {
|
|
827
|
+
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="glyph">🔍</div><h3>No pitches match</h3><p>Try a different filter or search term.</p></div>';
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
list.forEach(function (p) {
|
|
831
|
+
var total = p.totalScopes;
|
|
832
|
+
var statLabel = p.hasLocal ? 'scopes green' : 'scopes finished';
|
|
833
|
+
var statValue = p.hasLocal ? p.greenCount : p.finishedCount;
|
|
834
|
+
var card = document.createElement('button');
|
|
835
|
+
card.className = 'pitch-card';
|
|
836
|
+
var hillMarkup = p.hillAvailable
|
|
837
|
+
? renderHillSVG(p.scopeStatus, 'mini')
|
|
838
|
+
: '<div class="hill-empty-note">Hill not yet computed</div>';
|
|
839
|
+
card.innerHTML =
|
|
840
|
+
'<span class="stripe" style="background:var(' + HEALTH_COLOR_VAR[p.health] + ')"></span>' +
|
|
841
|
+
'<div class="body">' +
|
|
842
|
+
'<div class="top-row"><h3>' + p.slug + '</h3><span class="health-pill ' + p.health + '">' + HEALTH_LABEL[p.health] + '</span></div>' +
|
|
843
|
+
'<div class="phase-line">' + p.phase + '</div>' +
|
|
844
|
+
'<div class="card-hill">' + hillMarkup + '</div>' +
|
|
845
|
+
'<div class="card-stats">' +
|
|
846
|
+
'<div><b class="num">' + statValue + '/' + total + '</b>' + statLabel + '</div>' +
|
|
847
|
+
(p.hasLocal ? '<div><b class="num">' + (p.rounds.length || 1) + '</b>round' + (p.rounds.length === 1 ? '' : 's') + '</div>' : '') +
|
|
848
|
+
(p.model.dangling ? '<div><b class="num" style="color:var(--bad)">' + p.model.dangling + '</b>dangling</div>' : '') +
|
|
849
|
+
'</div>' +
|
|
850
|
+
'<div class="card-foot">' + (p.run ? fmtVal(p.run.attrs.started_at) : (p.hasLocal ? 'no run node' : 'local trace not present')) + '</div>' +
|
|
851
|
+
'</div>';
|
|
852
|
+
card.addEventListener('click', function () { openDetail(p.slug); });
|
|
853
|
+
grid.appendChild(card);
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ============================================================================================
|
|
858
|
+
// Detail screen
|
|
859
|
+
// ============================================================================================
|
|
860
|
+
function openDetail(slug) {
|
|
861
|
+
currentSlug = slug;
|
|
862
|
+
renderDetail();
|
|
863
|
+
showScreen('detail');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function gateStatusClass(g) {
|
|
867
|
+
if (!g) return 'pending';
|
|
868
|
+
if (g.attrs.decision === 'loop' || (g.attrs.status && g.attrs.status !== 'ok')) return 'warn';
|
|
869
|
+
return 'ok';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildSteps(pitch) {
|
|
873
|
+
var steps = [];
|
|
874
|
+
['L0', 'L1a', 'L1a.5', 'L1b'].forEach(function (name) {
|
|
875
|
+
var g = pitch.gates.filter(function (x) { return x.attrs.gate === name; }).pop();
|
|
876
|
+
steps.push({ label: name, sub: null, gate: g });
|
|
877
|
+
});
|
|
878
|
+
var rounds = pitch.rounds.length ? pitch.rounds : [1];
|
|
879
|
+
rounds.forEach(function (r) {
|
|
880
|
+
['L2', 'L3'].forEach(function (name) {
|
|
881
|
+
var g = pitch.gates.filter(function (x) { return x.attrs.gate === name && x.attrs.round === r; }).pop();
|
|
882
|
+
steps.push({ label: name, sub: 'round ' + r, gate: g });
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
var l4 = pitch.gates.filter(function (x) { return x.attrs.gate === 'L4'; }).pop();
|
|
886
|
+
steps.push({ label: 'L4', sub: 'ship', gate: l4 });
|
|
887
|
+
return steps;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function shortenMid(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
891
|
+
|
|
892
|
+
function renderDetail() {
|
|
893
|
+
var pitch = pitches.get(currentSlug);
|
|
894
|
+
var body = document.getElementById('detail-body');
|
|
895
|
+
if (!pitch) { body.innerHTML = ''; return; }
|
|
896
|
+
|
|
897
|
+
var total = pitch.totalScopes;
|
|
898
|
+
var html = '';
|
|
899
|
+
|
|
900
|
+
// ---- 1. hero / stat strip ----
|
|
901
|
+
html += '<div class="detail-hero">';
|
|
902
|
+
html += '<div class="hero-top"><h2>' + pitch.slug + '</h2><span class="health-pill ' + pitch.health + '">' + HEALTH_LABEL[pitch.health] + '</span></div>';
|
|
903
|
+
html += '<div class="phase-line" style="font-size:13.5px">' + pitch.phase + '</div>';
|
|
904
|
+
if (pitch.run) {
|
|
905
|
+
html += '<div class="meta-line">' +
|
|
906
|
+
'<span><b>run</b> ' + fmtVal(pitch.run.attrs.run_id) + '</span>' +
|
|
907
|
+
'<span><b>level</b> ' + fmtVal(pitch.run.attrs.auto_level) + '</span>' +
|
|
908
|
+
'<span><b>started</b> ' + fmtVal(pitch.run.attrs.started_at) + '</span></div>';
|
|
909
|
+
}
|
|
910
|
+
html += '<div class="hero-stats">' +
|
|
911
|
+
'<div><b class="num">' + total + '</b>scopes</div>' +
|
|
912
|
+
'<div><b class="num">' + pitch.finishedCount + '</b>finished</div>' +
|
|
913
|
+
(pitch.hasLocal ? '<div><b class="num">' + (pitch.rounds.length || 1) + '</b>round' + (pitch.rounds.length === 1 ? '' : 's') + '</div>' : '') +
|
|
914
|
+
(pitch.hasLocal ? '<div><b class="num" style="' + (pitch.model.dangling ? 'color:var(--bad)' : '') + '">' + pitch.model.dangling + '</b>dangling edges</div>' : '') +
|
|
915
|
+
'</div>';
|
|
916
|
+
html += '</div>';
|
|
917
|
+
|
|
918
|
+
// ---- 2. hill chart (headline) ----
|
|
919
|
+
html += '<div class="section"><h3>Hill Chart</h3><div class="sub">Where each scope sits between figuring it out and getting it done — mechanical, derived only from T0/T1/seesaw evidence, never self-reported. Position is how derisked a scope has ever been; dot color is its current-round health, so a scope can sit downhill and still show red if its latest attempt regressed.</div>';
|
|
920
|
+
html += '<div class="section-card"><div class="hill-wrap hero">';
|
|
921
|
+
if (pitch.hillAvailable) {
|
|
922
|
+
html += renderHillSVG(pitch.scopeStatus, 'hero');
|
|
923
|
+
html += '<div class="hill-legend">' +
|
|
924
|
+
'<span><i style="background:var(--good)"></i>Latest attempt green</span>' +
|
|
925
|
+
'<span><i style="background:var(--bad)"></i>Latest attempt red</span>' +
|
|
926
|
+
'<span><i style="background:var(--pending)"></i>No local status (archived/queued)</span>' +
|
|
927
|
+
'</div>';
|
|
928
|
+
} else {
|
|
929
|
+
html += '<div class="hill-empty-note">No hill data yet for this pitch — run <code>harness reduce hill --slug ' + pitch.slug + '</code> after a build round.</div>';
|
|
930
|
+
}
|
|
931
|
+
html += '</div></div></div>';
|
|
932
|
+
|
|
933
|
+
if (!pitch.hasLocal) {
|
|
934
|
+
html += '<div class="section"><div class="archived-note"><span>📦</span><span><b>Archived.</b> This pitch\'s local run trace (<code>.shapeup/' + pitch.slug + '/</code>) isn\'t present — likely cleaned up after shipping. The Hill Chart above still reflects its committed <code>hill/*.yml</code> shards, but current-round health, the scope board, round history and the raw graph all need the local trace and aren\'t available.</span></div></div>';
|
|
935
|
+
body.innerHTML = html;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ---- 3. attention callouts ----
|
|
940
|
+
var callouts = [];
|
|
941
|
+
pitch.scopeStatus.filter(function (s) { return s.status === 'red'; }).forEach(function (s) {
|
|
942
|
+
callouts.push({ level: 'bad', text: '<b>' + s.scopeId + '</b> is red on its latest verdict (round ' + (s.latest ? s.latest.attrs.round : '?') + ').' });
|
|
943
|
+
});
|
|
944
|
+
pitch.scopeStatus.filter(function (s) { return s.status != null && s.hillPhase === 'UPHILL_UNKNOWN'; }).forEach(function (s) {
|
|
945
|
+
callouts.push({ level: 'bad', text: '<b>' + s.scopeId + '</b> is still UPHILL_UNKNOWN despite build activity — an unresolved unknown may be blocking derisking.' });
|
|
946
|
+
});
|
|
947
|
+
pitch.verdicts.filter(function (v) { return v.attrs.regression; }).forEach(function (v) {
|
|
948
|
+
callouts.push({ level: 'bad', text: 'Regression flagged on <b>' + (v.attrs.scope_id || '?') + '</b> (' + v.id + ').' });
|
|
949
|
+
});
|
|
950
|
+
pitch.gates.filter(function (g) { return g.attrs.decision === 'loop'; }).forEach(function (g) {
|
|
951
|
+
callouts.push({ level: 'info', text: '<b>' + g.attrs.gate + '</b>' + (g.attrs.round != null ? ' · round ' + g.attrs.round : '') + ' looped back for another round.' });
|
|
952
|
+
});
|
|
953
|
+
if (pitch.model.dangling) callouts.push({ level: 'info', text: pitch.model.dangling + ' edge(s) point at an artifact not present in this file — likely still in flight.' });
|
|
954
|
+
|
|
955
|
+
if (callouts.length) {
|
|
956
|
+
html += '<div class="section"><h3>Needs attention</h3><div class="sub">Surfaced automatically from red verdicts, stuck unknowns, regressions, loop decisions and dangling references.</div>';
|
|
957
|
+
html += '<ul class="attention-list">' + callouts.map(function (c) {
|
|
958
|
+
return '<li class="' + (c.level === 'info' ? 'info' : '') + '"><span class="icon">' + (c.level === 'info' ? '↻' : '⚠') + '</span><span>' + c.text + '</span></li>';
|
|
959
|
+
}).join('') + '</ul></div>';
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ---- 4. scope board ----
|
|
963
|
+
var buckets = { queued: [], building: [], green: [], red: [] };
|
|
964
|
+
pitch.scopeStatus.forEach(function (s) { if (buckets[s.status]) buckets[s.status].push(s); });
|
|
965
|
+
var colDefs = [
|
|
966
|
+
['queued', 'Queued', '--pending'], ['building', 'Building', '--warn'],
|
|
967
|
+
['green', 'Green', '--good'], ['red', 'Needs fix', '--bad']
|
|
968
|
+
];
|
|
969
|
+
html += '<div class="section"><h3>Scopes</h3><div class="sub">One card per unit of work this build cut — the substrate the scope architect handed to task-executor.</div><div class="section-card"><div class="board">';
|
|
970
|
+
colDefs.forEach(function (d) {
|
|
971
|
+
var items = buckets[d[0]];
|
|
972
|
+
html += '<div class="board-col"><div class="board-col-head"><span class="dot" style="background:var(' + d[2] + ')"></span><span>' + d[1] + '</span><b class="num">' + items.length + '</b></div>';
|
|
973
|
+
if (!items.length) html += '<div class="board-empty">None</div>';
|
|
974
|
+
items.forEach(function (s) {
|
|
975
|
+
html += '<div class="scope-card" id="scope-card-' + s.scopeId + '"><div class="name">' + s.scopeId + '</div><div class="sub">' +
|
|
976
|
+
(s.latest ? ('r' + s.latest.attrs.round + ' · a' + s.latest.attrs.attempt) : 'not yet built') +
|
|
977
|
+
(s.attempts > 1 ? ' · ' + s.attempts + ' attempts' : '') + '</div></div>';
|
|
978
|
+
});
|
|
979
|
+
html += '</div>';
|
|
980
|
+
});
|
|
981
|
+
html += '</div></div></div>';
|
|
982
|
+
|
|
983
|
+
// ---- 5. round matrix ----
|
|
984
|
+
if (pitch.rounds.length && pitch.scopeStatus.length) {
|
|
985
|
+
html += '<div class="section"><h3>Round history</h3><div class="sub">Each cell is that scope\'s last T0 verdict for the round — a quick read on which rounds needed a retry.</div><div class="section-card"><div class="matrix-scroll"><table class="matrix"><thead><tr><th class="rowhead">Round</th>';
|
|
986
|
+
pitch.scopeStatus.forEach(function (s) { html += '<th title="' + s.scopeId + '">' + shortenMid(s.scopeId, 12) + '</th>'; });
|
|
987
|
+
html += '</tr></thead><tbody>';
|
|
988
|
+
pitch.rounds.forEach(function (r) {
|
|
989
|
+
html += '<tr><td class="rowhead">Round ' + r + '</td>';
|
|
990
|
+
pitch.scopeStatus.forEach(function (s) {
|
|
991
|
+
var vs = pitch.verdicts.filter(function (v) { return v.attrs.scope_id === s.scopeId && v.attrs.round === r; })
|
|
992
|
+
.sort(function (a, b) { return a.attrs.attempt - b.attrs.attempt; });
|
|
993
|
+
if (!vs.length) { html += '<td><span class="verdict-chip na">·</span></td>'; return; }
|
|
994
|
+
var last = vs[vs.length - 1];
|
|
995
|
+
var ok = last.attrs.overall === 'green';
|
|
996
|
+
html += '<td><span class="verdict-chip ' + (ok ? 'green' : 'red') + '" title="' + last.id + '">' + (ok ? '✓' : '✕') + '</span>' +
|
|
997
|
+
(vs.length > 1 ? '<div style="font-size:9.5px;color:var(--text-faint)">×' + vs.length + '</div>' : '') + '</td>';
|
|
998
|
+
});
|
|
999
|
+
html += '</tr>';
|
|
1000
|
+
});
|
|
1001
|
+
html += '</tbody></table></div></div></div>';
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ---- 6. gate history (collapsed — demoted now that the hill carries the headline) ----
|
|
1005
|
+
html += '<div class="section"><details class="gate-history"><summary>Gate history</summary>';
|
|
1006
|
+
html += '<div class="sub">Every checkpoint this build has crossed, or is waiting on.</div>';
|
|
1007
|
+
if (pitch.gates.length) {
|
|
1008
|
+
html += '<div class="stepper" id="stepper"></div><div id="gate-detail"></div>';
|
|
1009
|
+
} else {
|
|
1010
|
+
html += '<div class="board-empty">No gate decisions recorded in this file — this trace didn\'t carry a gate ledger, even though the build above completed.</div>';
|
|
1011
|
+
}
|
|
1012
|
+
html += '</details></div>';
|
|
1013
|
+
|
|
1014
|
+
// ---- 7. raw graph launch ----
|
|
1015
|
+
html += '<div class="section"><div class="section-card raw-launch">' +
|
|
1016
|
+
'<div><h3 style="margin-bottom:4px">Explore the full graph</h3><p>Every node and edge this file carries — trace a verdict back to its order, scope and requirements.</p></div>' +
|
|
1017
|
+
'<button class="btn primary" id="open-raw">Open raw graph →</button></div></div>';
|
|
1018
|
+
|
|
1019
|
+
body.innerHTML = html;
|
|
1020
|
+
renderStepper(pitch);
|
|
1021
|
+
wireHillDots();
|
|
1022
|
+
document.getElementById('open-raw').addEventListener('click', function () { openRaw(pitch.slug); });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function wireHillDots() {
|
|
1026
|
+
document.querySelectorAll('#detail-body .hill-svg .hill-dot[data-scope]').forEach(function (dot) {
|
|
1027
|
+
dot.addEventListener('click', function () {
|
|
1028
|
+
var id = dot.getAttribute('data-scope');
|
|
1029
|
+
var card = document.getElementById('scope-card-' + id);
|
|
1030
|
+
if (!card) return;
|
|
1031
|
+
card.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'center' });
|
|
1032
|
+
card.classList.add('flash');
|
|
1033
|
+
setTimeout(function () { card.classList.remove('flash'); }, 900);
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function renderStepper(pitch) {
|
|
1039
|
+
var el = document.getElementById('stepper');
|
|
1040
|
+
if (!el) return;
|
|
1041
|
+
el.innerHTML = '';
|
|
1042
|
+
buildSteps(pitch).forEach(function (step, i) {
|
|
1043
|
+
var cls = step.gate ? gateStatusClass(step.gate) : 'pending';
|
|
1044
|
+
var div = document.createElement('div');
|
|
1045
|
+
div.className = 'step ' + cls;
|
|
1046
|
+
div.innerHTML = '<span class="connector"></span>' +
|
|
1047
|
+
'<span class="node">' + (cls === 'ok' ? '✓' : cls === 'warn' ? '↻' : (i + 1)) + '</span>' +
|
|
1048
|
+
'<span class="label">' + step.label + '</span>' +
|
|
1049
|
+
(step.sub ? '<span class="sub-label">' + step.sub + '</span>' : '') +
|
|
1050
|
+
'<button class="hit" aria-label="Details for ' + step.label + '"></button>';
|
|
1051
|
+
div.querySelector('.hit').addEventListener('click', function () { showGateDetail(step); });
|
|
1052
|
+
el.appendChild(div);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function showGateDetail(step) {
|
|
1057
|
+
var box = document.getElementById('gate-detail');
|
|
1058
|
+
if (!step.gate) {
|
|
1059
|
+
box.innerHTML = '<b>' + step.label + '</b> — not reached yet.';
|
|
1060
|
+
} else {
|
|
1061
|
+
var g = step.gate.attrs;
|
|
1062
|
+
box.innerHTML = '<b>' + step.label + (step.sub ? ' · ' + step.sub : '') + '</b> — ' + fmtVal(g.decision) + ' (' + fmtVal(g.status) + ') via <code>' + fmtVal(g.source) + '</code>' +
|
|
1063
|
+
(g.note ? '<br>' + g.note : '');
|
|
1064
|
+
}
|
|
1065
|
+
box.classList.add('show');
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
document.getElementById('back-to-portfolio').addEventListener('click', function () { showScreen('portfolio'); });
|
|
1069
|
+
|
|
1070
|
+
// ============================================================================================
|
|
1071
|
+
// Raw graph screen — the force-directed node/edge explorer, scoped to one pitch's model.
|
|
1072
|
+
// ============================================================================================
|
|
1073
|
+
var svg = document.getElementById('graph-svg');
|
|
1074
|
+
var viewport = document.getElementById('viewport');
|
|
1075
|
+
var edgesLayer = document.getElementById('edges-layer');
|
|
1076
|
+
var nodesLayer = document.getElementById('nodes-layer');
|
|
1077
|
+
var canvasWrap = document.getElementById('canvas-wrap');
|
|
1078
|
+
var emptyHint = document.getElementById('empty-hint');
|
|
1079
|
+
var legendRail = document.getElementById('legend-rail');
|
|
1080
|
+
var inspectorBody = document.getElementById('inspector-body');
|
|
1081
|
+
|
|
1082
|
+
var rawModel = { nodeList: [], linkList: [], byId: new Map(), dangling: 0 };
|
|
1083
|
+
var activeTypes = new Set(TYPE_ORDER.concat(['Unknown']));
|
|
1084
|
+
var selected = null, hoveredEl = null;
|
|
1085
|
+
var width = 800, height = 600, tf = { x: 0, y: 0, k: 1 }, alpha = 1, raf = null;
|
|
1086
|
+
var nodeEls = new Map(), edgeEls = new Map();
|
|
1087
|
+
|
|
1088
|
+
function openRaw(slug) {
|
|
1089
|
+
var pitch = pitches.get(slug);
|
|
1090
|
+
if (!pitch || !pitch.hasLocal) return;
|
|
1091
|
+
rawModel = pitch.model;
|
|
1092
|
+
activeTypes = new Set(TYPE_ORDER.concat(['Unknown']));
|
|
1093
|
+
buildLegend();
|
|
1094
|
+
handleResize();
|
|
1095
|
+
seedPositions(rawModel.nodeList);
|
|
1096
|
+
selected = null;
|
|
1097
|
+
rebuildDOM();
|
|
1098
|
+
runSimulation(true);
|
|
1099
|
+
setTimeout(fitToView, reduceMotion ? 60 : 450);
|
|
1100
|
+
showScreen('raw');
|
|
1101
|
+
}
|
|
1102
|
+
document.getElementById('back-to-detail').addEventListener('click', function () { showScreen('detail'); });
|
|
1103
|
+
|
|
1104
|
+
function seedPositions(nodeList) {
|
|
1105
|
+
var workTypes = TYPE_ORDER.filter(function (t) { return NODE_META[t].family === 'work'; });
|
|
1106
|
+
var domainTypes = TYPE_ORDER.filter(function (t) { return NODE_META[t].family === 'domain'; });
|
|
1107
|
+
var laneX = {};
|
|
1108
|
+
workTypes.forEach(function (t, i) { laneX[t] = width * 0.24 + i * (width * 0.09); });
|
|
1109
|
+
domainTypes.forEach(function (t, i) { laneX[t] = width * 0.66 + i * (width * 0.10); });
|
|
1110
|
+
var counters = {};
|
|
1111
|
+
nodeList.forEach(function (n) {
|
|
1112
|
+
var baseX = laneX[n.t] != null ? laneX[n.t] : width * 0.5;
|
|
1113
|
+
var c = (counters[n.t] = (counters[n.t] || 0) + 1);
|
|
1114
|
+
n.x = baseX + (Math.random() - 0.5) * 50;
|
|
1115
|
+
n.y = 70 + ((c * 52) % Math.max(height - 140, 120)) + (Math.random() - 0.5) * 30;
|
|
1116
|
+
n.vx = 0; n.vy = 0; n.pinned = false;
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function tick(a) {
|
|
1121
|
+
var nodes = rawModel.nodeList;
|
|
1122
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
1123
|
+
var A = nodes[i];
|
|
1124
|
+
for (var j = i + 1; j < nodes.length; j++) {
|
|
1125
|
+
var B = nodes[j];
|
|
1126
|
+
var dx = A.x - B.x, dy = A.y - B.y, d2 = dx * dx + dy * dy;
|
|
1127
|
+
if (d2 < 1) { dx = (Math.random() - 0.5) || 0.1; dy = (Math.random() - 0.5) || 0.1; d2 = 1; }
|
|
1128
|
+
var dist = Math.sqrt(d2), f = (2200 / d2) * a;
|
|
1129
|
+
var fx = (dx / dist) * f, fy = (dy / dist) * f;
|
|
1130
|
+
if (!A.pinned) { A.vx += fx; A.vy += fy; }
|
|
1131
|
+
if (!B.pinned) { B.vx -= fx; B.vy -= fy; }
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (var e = 0; e < rawModel.linkList.length; e++) {
|
|
1135
|
+
var L = rawModel.linkList[e], A2 = L.from, B2 = L.to;
|
|
1136
|
+
var dx2 = B2.x - A2.x, dy2 = B2.y - A2.y, dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 0.01;
|
|
1137
|
+
var fs = (dist2 - 100) * 0.028 * a, fx2 = (dx2 / dist2) * fs, fy2 = (dy2 / dist2) * fs;
|
|
1138
|
+
if (!A2.pinned) { A2.vx += fx2; A2.vy += fy2; }
|
|
1139
|
+
if (!B2.pinned) { B2.vx -= fx2; B2.vy -= fy2; }
|
|
1140
|
+
}
|
|
1141
|
+
var cx = width / 2, cy = height / 2;
|
|
1142
|
+
for (var n = 0; n < nodes.length; n++) {
|
|
1143
|
+
var N = nodes[n];
|
|
1144
|
+
if (N.pinned) { N.vx = 0; N.vy = 0; continue; }
|
|
1145
|
+
N.vx += (cx - N.x) * 0.0006 * a; N.vy += (cy - N.y) * 0.0006 * a;
|
|
1146
|
+
N.vx *= 0.82; N.vy *= 0.82; N.x += N.vx; N.y += N.vy;
|
|
1147
|
+
N.x = Math.max(26, Math.min(width - 26, N.x)); N.y = Math.max(26, Math.min(height - 26, N.y));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function runSimulation(resetAlpha) {
|
|
1152
|
+
if (resetAlpha) alpha = 1;
|
|
1153
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1154
|
+
var maxTicks = reduceMotion ? 90 : 420, count = 0;
|
|
1155
|
+
function frame() {
|
|
1156
|
+
if (alpha > 0.006 && count < maxTicks) {
|
|
1157
|
+
tick(alpha); alpha *= 0.985; count++; updatePositions();
|
|
1158
|
+
raf = requestAnimationFrame(frame);
|
|
1159
|
+
} else { updatePositions(); raf = null; }
|
|
1160
|
+
}
|
|
1161
|
+
frame();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
1165
|
+
function el(tag, attrs) { var e = document.createElementNS(SVG_NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); return e; }
|
|
1166
|
+
function shortLabel(id) { var idx = id.indexOf(':'); var s = idx >= 0 ? id.slice(idx + 1) : id; if (s.length > 26) s = s.slice(0, 12) + '…' + s.slice(-11); return s; }
|
|
1167
|
+
function radiusFor(n) { return 9 + Math.min(n.degree, 9) * 0.85; }
|
|
1168
|
+
function ringColor(n) { if (n.t !== 'Verdict') return null; if (n.attrs.overall === 'green') return 'var(--good)'; if (n.attrs.overall != null) return 'var(--bad)'; return null; }
|
|
1169
|
+
|
|
1170
|
+
function rebuildDOM() {
|
|
1171
|
+
edgesLayer.innerHTML = ''; nodesLayer.innerHTML = ''; nodeEls.clear(); edgeEls.clear();
|
|
1172
|
+
rawModel.linkList.forEach(function (L) {
|
|
1173
|
+
var line = el('line', { class: 'edge t-' + L.t, 'marker-end': 'url(#arrow)' });
|
|
1174
|
+
line.addEventListener('click', function (ev) { ev.stopPropagation(); selectEdge(L); });
|
|
1175
|
+
edgesLayer.appendChild(line); edgeEls.set(L.key, line);
|
|
1176
|
+
});
|
|
1177
|
+
rawModel.nodeList.forEach(function (n) {
|
|
1178
|
+
var meta = metaFor(n.t), g = el('g', { class: 'node-g' }), r = radiusFor(n);
|
|
1179
|
+
var halo = el('circle', { class: 'halo', r: r + 5 });
|
|
1180
|
+
var ring = el('circle', { class: 'status-ring', r: r + 2.5, stroke: ringColor(n) || 'none' });
|
|
1181
|
+
var dot = el('circle', { class: 'node-dot', r: r, fill: meta.color });
|
|
1182
|
+
var label = el('text', { y: r + 12, 'text-anchor': 'middle' }); label.textContent = shortLabel(n.id);
|
|
1183
|
+
var titleEl = el('title', {}); titleEl.textContent = n.t + ' · ' + n.id;
|
|
1184
|
+
g.appendChild(halo); if (ringColor(n)) g.appendChild(ring); g.appendChild(dot); g.appendChild(titleEl); g.appendChild(label);
|
|
1185
|
+
g.addEventListener('pointerenter', function () { setHover(n.id); });
|
|
1186
|
+
g.addEventListener('pointerleave', function () { setHover(null); });
|
|
1187
|
+
g.addEventListener('click', function (ev) { ev.stopPropagation(); selectNode(n); });
|
|
1188
|
+
attachDrag(g, n);
|
|
1189
|
+
nodesLayer.appendChild(g); nodeEls.set(n.id, { g: g });
|
|
1190
|
+
});
|
|
1191
|
+
applyFiltersAndSearch(); updatePositions();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function updatePositions() {
|
|
1195
|
+
rawModel.linkList.forEach(function (L) {
|
|
1196
|
+
var line = edgeEls.get(L.key); if (!line) return;
|
|
1197
|
+
line.setAttribute('x1', L.from.x); line.setAttribute('y1', L.from.y);
|
|
1198
|
+
line.setAttribute('x2', L.to.x); line.setAttribute('y2', L.to.y);
|
|
1199
|
+
});
|
|
1200
|
+
rawModel.nodeList.forEach(function (n) {
|
|
1201
|
+
var rec = nodeEls.get(n.id); if (!rec) return;
|
|
1202
|
+
rec.g.setAttribute('transform', 'translate(' + n.x + ',' + n.y + ')');
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function rootPoint(clientX, clientY) {
|
|
1207
|
+
var pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
|
|
1208
|
+
var ctm = svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 };
|
|
1209
|
+
var p = pt.matrixTransform(ctm.inverse()); return { x: p.x, y: p.y };
|
|
1210
|
+
}
|
|
1211
|
+
function applyTransform() { viewport.setAttribute('transform', 'translate(' + tf.x + ',' + tf.y + ') scale(' + tf.k + ')'); }
|
|
1212
|
+
|
|
1213
|
+
function attachDrag(g, n) {
|
|
1214
|
+
var dragging = false, offX = 0, offY = 0;
|
|
1215
|
+
g.addEventListener('pointerdown', function (ev) {
|
|
1216
|
+
ev.stopPropagation(); dragging = true; g.setPointerCapture(ev.pointerId);
|
|
1217
|
+
var rp = rootPoint(ev.clientX, ev.clientY);
|
|
1218
|
+
offX = (rp.x - tf.x) / tf.k - n.x; offY = (rp.y - tf.y) / tf.k - n.y;
|
|
1219
|
+
});
|
|
1220
|
+
g.addEventListener('pointermove', function (ev) {
|
|
1221
|
+
if (!dragging) return; n.pinned = true;
|
|
1222
|
+
var rp = rootPoint(ev.clientX, ev.clientY);
|
|
1223
|
+
n.x = (rp.x - tf.x) / tf.k - offX; n.y = (rp.y - tf.y) / tf.k - offY;
|
|
1224
|
+
n.vx = 0; n.vy = 0; updatePositions();
|
|
1225
|
+
});
|
|
1226
|
+
function end(ev) { if (dragging) { dragging = false; try { g.releasePointerCapture(ev.pointerId); } catch (e) {} } }
|
|
1227
|
+
g.addEventListener('pointerup', end); g.addEventListener('pointercancel', end);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
var panning = false, panStart = null, tfStart = null;
|
|
1231
|
+
svg.addEventListener('pointerdown', function (ev) {
|
|
1232
|
+
if (ev.target !== svg) return;
|
|
1233
|
+
panning = true; svg.classList.add('panning'); svg.setPointerCapture(ev.pointerId);
|
|
1234
|
+
panStart = { x: ev.clientX, y: ev.clientY }; tfStart = { x: tf.x, y: tf.y };
|
|
1235
|
+
clearSelection();
|
|
1236
|
+
});
|
|
1237
|
+
svg.addEventListener('pointermove', function (ev) {
|
|
1238
|
+
if (!panning) return;
|
|
1239
|
+
var r0 = rootPoint(panStart.x, panStart.y), r1 = rootPoint(ev.clientX, ev.clientY);
|
|
1240
|
+
tf.x = tfStart.x + (r1.x - r0.x); tf.y = tfStart.y + (r1.y - r0.y); applyTransform();
|
|
1241
|
+
});
|
|
1242
|
+
function panEnd(ev) { if (panning) { panning = false; svg.classList.remove('panning'); try { svg.releasePointerCapture(ev.pointerId); } catch (e) {} } }
|
|
1243
|
+
svg.addEventListener('pointerup', panEnd); svg.addEventListener('pointercancel', panEnd);
|
|
1244
|
+
svg.addEventListener('wheel', function (ev) {
|
|
1245
|
+
ev.preventDefault();
|
|
1246
|
+
var rp = rootPoint(ev.clientX, ev.clientY), factor = Math.exp(-ev.deltaY * 0.0016);
|
|
1247
|
+
var newK = Math.max(0.15, Math.min(4, tf.k * factor));
|
|
1248
|
+
tf.x = rp.x - (rp.x - tf.x) * (newK / tf.k); tf.y = rp.y - (rp.y - tf.y) * (newK / tf.k); tf.k = newK;
|
|
1249
|
+
applyTransform();
|
|
1250
|
+
}, { passive: false });
|
|
1251
|
+
function zoomBy(factor) {
|
|
1252
|
+
var cx = width / 2, cy = height / 2, newK = Math.max(0.15, Math.min(4, tf.k * factor));
|
|
1253
|
+
tf.x = cx - (cx - tf.x) * (newK / tf.k); tf.y = cy - (cy - tf.y) * (newK / tf.k); tf.k = newK; applyTransform();
|
|
1254
|
+
}
|
|
1255
|
+
document.getElementById('zoom-in').addEventListener('click', function () { zoomBy(1.25); });
|
|
1256
|
+
document.getElementById('zoom-out').addEventListener('click', function () { zoomBy(0.8); });
|
|
1257
|
+
document.getElementById('zoom-reset').addEventListener('click', function () { tf = { x: 0, y: 0, k: 1 }; applyTransform(); });
|
|
1258
|
+
|
|
1259
|
+
function fitToView() {
|
|
1260
|
+
var visible = rawModel.nodeList.filter(function (n) { return activeTypes.has(n.t); });
|
|
1261
|
+
if (!visible.length) { tf = { x: 0, y: 0, k: 1 }; applyTransform(); return; }
|
|
1262
|
+
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1263
|
+
visible.forEach(function (n) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); });
|
|
1264
|
+
var pad = 60, gw = Math.max(maxX - minX, 1) + pad * 2, gh = Math.max(maxY - minY, 1) + pad * 2;
|
|
1265
|
+
var k = Math.max(0.15, Math.min(2.4, Math.min(width / gw, height / gh)));
|
|
1266
|
+
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
|
1267
|
+
tf.k = k; tf.x = width / 2 - cx * k; tf.y = height / 2 - cy * k; applyTransform();
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function setHover(id) {
|
|
1271
|
+
if (hoveredEl) { var prev = nodeEls.get(hoveredEl); if (prev) prev.g.classList.remove('hovered'); }
|
|
1272
|
+
hoveredEl = id;
|
|
1273
|
+
if (id) { var rec = nodeEls.get(id); if (rec) rec.g.classList.add('hovered'); }
|
|
1274
|
+
}
|
|
1275
|
+
function clearSelection() {
|
|
1276
|
+
selected = null;
|
|
1277
|
+
nodeEls.forEach(function (rec) { rec.g.classList.remove('selected', 'dim'); });
|
|
1278
|
+
edgeEls.forEach(function (line) { line.classList.remove('active', 'dim'); });
|
|
1279
|
+
applyFiltersAndSearch();
|
|
1280
|
+
inspectorBody.className = 'insp-empty'; inspectorBody.textContent = 'Click a node or edge to inspect it.';
|
|
1281
|
+
}
|
|
1282
|
+
svg.addEventListener('click', function (ev) { if (ev.target === svg) clearSelection(); });
|
|
1283
|
+
|
|
1284
|
+
function neighborSetOf(n) {
|
|
1285
|
+
var ids = new Set([n.id]), edgeKeys = new Set();
|
|
1286
|
+
rawModel.linkList.forEach(function (L) { if (L.from === n || L.to === n) { ids.add(L.from.id); ids.add(L.to.id); edgeKeys.add(L.key); } });
|
|
1287
|
+
return { ids: ids, edgeKeys: edgeKeys };
|
|
1288
|
+
}
|
|
1289
|
+
function selectNode(n) {
|
|
1290
|
+
selected = { kind: 'node', ref: n };
|
|
1291
|
+
var neigh = neighborSetOf(n);
|
|
1292
|
+
nodeEls.forEach(function (rec, id) { rec.g.classList.toggle('selected', id === n.id); rec.g.classList.toggle('dim', !neigh.ids.has(id)); });
|
|
1293
|
+
edgeEls.forEach(function (line, key) { line.classList.toggle('active', neigh.edgeKeys.has(key)); line.classList.toggle('dim', !neigh.edgeKeys.has(key)); });
|
|
1294
|
+
applyTypeVisibility(); renderInspectorNode(n);
|
|
1295
|
+
}
|
|
1296
|
+
function selectEdge(L) {
|
|
1297
|
+
selected = { kind: 'edge', ref: L };
|
|
1298
|
+
nodeEls.forEach(function (rec, id) { rec.g.classList.remove('selected'); rec.g.classList.toggle('dim', id !== L.from.id && id !== L.to.id); });
|
|
1299
|
+
edgeEls.forEach(function (line, key) { line.classList.toggle('active', key === L.key); line.classList.toggle('dim', key !== L.key); });
|
|
1300
|
+
applyTypeVisibility(); renderInspectorEdge(L);
|
|
1301
|
+
}
|
|
1302
|
+
function jumpChip(id) {
|
|
1303
|
+
var row = document.createElement('div'); row.className = 'conn-row';
|
|
1304
|
+
var n = rawModel.byId.get(id), meta = n ? metaFor(n.t) : { color: 'var(--n-other)' };
|
|
1305
|
+
row.innerHTML = '<span class="swatch" style="background:' + meta.color + '"></span><span class="edge-t">' + (n ? n.t : '?') + '</span><span class="id">' + id + '</span>';
|
|
1306
|
+
row.addEventListener('click', function () { if (n) { selectNode(n); centerOn(n); } });
|
|
1307
|
+
return row;
|
|
1308
|
+
}
|
|
1309
|
+
function centerOn(n) { tf.x = width / 2 - n.x * tf.k; tf.y = height / 2 - n.y * tf.k; applyTransform(); }
|
|
1310
|
+
|
|
1311
|
+
function renderInspectorNode(n) {
|
|
1312
|
+
inspectorBody.className = '';
|
|
1313
|
+
var meta = metaFor(n.t), html = '';
|
|
1314
|
+
html += '<div class="insp-head"><span class="swatch" style="background:' + meta.color + '"></span><span class="type">' + n.t + '</span></div>';
|
|
1315
|
+
html += '<div class="insp-id">' + n.id + '</div><table class="kv"><tbody>';
|
|
1316
|
+
Object.keys(n.attrs).sort().forEach(function (k) {
|
|
1317
|
+
if (k === 'k' || k === 'id' || k === 't') return;
|
|
1318
|
+
html += '<tr><th>' + k + '</th><td>' + fmtVal(n.attrs[k]) + '</td></tr>';
|
|
1319
|
+
});
|
|
1320
|
+
html += '</tbody></table>';
|
|
1321
|
+
inspectorBody.innerHTML = html;
|
|
1322
|
+
var incoming = rawModel.linkList.filter(function (L) { return L.to === n; });
|
|
1323
|
+
var outgoing = rawModel.linkList.filter(function (L) { return L.from === n; });
|
|
1324
|
+
if (incoming.length) {
|
|
1325
|
+
var h1 = document.createElement('div'); h1.className = 'conn-group'; h1.innerHTML = '<h4>Incoming (' + incoming.length + ')</h4>';
|
|
1326
|
+
incoming.forEach(function (L) { var row = jumpChip(L.from.id); row.querySelector('.edge-t').textContent = L.t; h1.appendChild(row); });
|
|
1327
|
+
inspectorBody.appendChild(h1);
|
|
1328
|
+
}
|
|
1329
|
+
if (outgoing.length) {
|
|
1330
|
+
var h2 = document.createElement('div'); h2.className = 'conn-group'; h2.innerHTML = '<h4>Outgoing (' + outgoing.length + ')</h4>';
|
|
1331
|
+
outgoing.forEach(function (L) { var row = jumpChip(L.to.id); row.querySelector('.edge-t').textContent = L.t; h2.appendChild(row); });
|
|
1332
|
+
inspectorBody.appendChild(h2);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function renderInspectorEdge(L) {
|
|
1336
|
+
inspectorBody.className = '';
|
|
1337
|
+
inspectorBody.innerHTML = '<div class="insp-head"><span class="type">Edge · ' + L.t + '</span></div><table class="kv"><tbody>' +
|
|
1338
|
+
'<tr><th>from</th><td>' + L.from.id + '</td></tr><tr><th>type</th><td>' + L.t + '</td></tr><tr><th>to</th><td>' + L.to.id + '</td></tr></tbody></table>';
|
|
1339
|
+
inspectorBody.appendChild(jumpChip(L.from.id)); inspectorBody.appendChild(jumpChip(L.to.id));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function buildLegend() {
|
|
1343
|
+
legendRail.innerHTML = '';
|
|
1344
|
+
['work', 'domain'].forEach(function (fam) {
|
|
1345
|
+
var group = document.createElement('div'); group.className = 'legend-group';
|
|
1346
|
+
var h = document.createElement('h2'); h.textContent = fam === 'work' ? 'Work lineage' : 'Domain'; group.appendChild(h);
|
|
1347
|
+
TYPE_ORDER.filter(function (t) { return NODE_META[t].family === fam; }).forEach(function (t) { group.appendChild(chipFor(t)); });
|
|
1348
|
+
legendRail.appendChild(group);
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
function chipFor(t) {
|
|
1352
|
+
var btn = document.createElement('button'); btn.className = 'chip'; btn.dataset.type = t;
|
|
1353
|
+
var meta = metaFor(t);
|
|
1354
|
+
btn.innerHTML = '<span class="swatch" style="background:' + meta.color + '"></span><span class="label">' + t + '</span><span class="count">0</span>';
|
|
1355
|
+
btn.addEventListener('click', function () {
|
|
1356
|
+
if (activeTypes.has(t)) activeTypes.delete(t); else activeTypes.add(t);
|
|
1357
|
+
btn.classList.toggle('off', !activeTypes.has(t)); applyFiltersAndSearch();
|
|
1358
|
+
});
|
|
1359
|
+
return btn;
|
|
1360
|
+
}
|
|
1361
|
+
function refreshLegendCounts() {
|
|
1362
|
+
var counts = {};
|
|
1363
|
+
rawModel.nodeList.forEach(function (n) { counts[n.t] = (counts[n.t] || 0) + 1; });
|
|
1364
|
+
legendRail.querySelectorAll('.chip').forEach(function (chip) {
|
|
1365
|
+
var t = chip.dataset.type;
|
|
1366
|
+
chip.querySelector('.count').textContent = counts[t] || 0;
|
|
1367
|
+
chip.classList.toggle('off', !activeTypes.has(t));
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
function applyTypeVisibility() {
|
|
1371
|
+
rawModel.nodeList.forEach(function (n) { var rec = nodeEls.get(n.id); if (rec) rec.g.style.display = activeTypes.has(n.t) ? '' : 'none'; });
|
|
1372
|
+
rawModel.linkList.forEach(function (L) { var line = edgeEls.get(L.key); if (line) line.style.display = (activeTypes.has(L.from.t) && activeTypes.has(L.to.t)) ? '' : 'none'; });
|
|
1373
|
+
var anyVisible = rawModel.nodeList.some(function (n) { return activeTypes.has(n.t); });
|
|
1374
|
+
emptyHint.classList.toggle('show', rawModel.nodeList.length === 0 || !anyVisible);
|
|
1375
|
+
}
|
|
1376
|
+
function applyFiltersAndSearch() { applyTypeVisibility(); refreshLegendCounts(); }
|
|
1377
|
+
|
|
1378
|
+
function handleResize() {
|
|
1379
|
+
var w = canvasWrap.clientWidth, h = canvasWrap.clientHeight;
|
|
1380
|
+
if (!w || !h) return;
|
|
1381
|
+
width = w; height = h; svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
|
1382
|
+
}
|
|
1383
|
+
if (window.ResizeObserver) new ResizeObserver(handleResize).observe(canvasWrap);
|
|
1384
|
+
else window.addEventListener('resize', handleResize);
|
|
1385
|
+
|
|
1386
|
+
// ============================================================================================
|
|
1387
|
+
// Manual loading (multi-file, paste) — an escape hatch for comparing another project's
|
|
1388
|
+
// graph.jsonl. Manually-loaded pitches carry no hill data (that lives in a separate committed
|
|
1389
|
+
// shard this file can't see), so their Hill Chart shows as not yet available rather than
|
|
1390
|
+
// guessing a position for it.
|
|
1391
|
+
// ============================================================================================
|
|
1392
|
+
document.getElementById('file-input').addEventListener('change', function (ev) {
|
|
1393
|
+
var files = Array.prototype.slice.call(ev.target.files || []);
|
|
1394
|
+
if (!files.length) return;
|
|
1395
|
+
var pending = files.length;
|
|
1396
|
+
files.forEach(function (file) {
|
|
1397
|
+
var reader = new FileReader();
|
|
1398
|
+
reader.onload = function () {
|
|
1399
|
+
var text = String(reader.result || '');
|
|
1400
|
+
var label = file.name.replace(/\.[^.]+$/, '');
|
|
1401
|
+
var slug = slugFor(parseGraph(text), label);
|
|
1402
|
+
addPitch({ slug: slug, hasLocal: true, hasCommitted: false, hill: [], graphJsonl: text });
|
|
1403
|
+
pending--;
|
|
1404
|
+
if (pending === 0) { renderPortfolio(); showScreen('portfolio'); }
|
|
1405
|
+
};
|
|
1406
|
+
reader.readAsText(file);
|
|
1407
|
+
});
|
|
1408
|
+
ev.target.value = '';
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
var overlay = document.getElementById('overlay');
|
|
1412
|
+
var pasteArea = document.getElementById('paste-area');
|
|
1413
|
+
var pasteLabel = document.getElementById('paste-label');
|
|
1414
|
+
document.getElementById('btn-paste').addEventListener('click', function () { overlay.classList.add('show'); pasteArea.focus(); });
|
|
1415
|
+
document.getElementById('paste-cancel').addEventListener('click', function () { overlay.classList.remove('show'); });
|
|
1416
|
+
document.getElementById('paste-apply').addEventListener('click', function () {
|
|
1417
|
+
if (pasteArea.value.trim()) {
|
|
1418
|
+
var text = pasteArea.value;
|
|
1419
|
+
var slug = slugFor(parseGraph(text), pasteLabel.value.trim() || null);
|
|
1420
|
+
addPitch({ slug: slug, hasLocal: true, hasCommitted: false, hill: [], graphJsonl: text });
|
|
1421
|
+
renderPortfolio(); showScreen('portfolio');
|
|
1422
|
+
}
|
|
1423
|
+
overlay.classList.remove('show'); pasteArea.value = ''; pasteLabel.value = '';
|
|
1424
|
+
});
|
|
1425
|
+
overlay.addEventListener('click', function (ev) { if (ev.target === overlay) overlay.classList.remove('show'); });
|
|
1426
|
+
|
|
1427
|
+
// ============================================================================================
|
|
1428
|
+
// Boot
|
|
1429
|
+
// ============================================================================================
|
|
1430
|
+
loadEmbedded();
|
|
1431
|
+
})();
|
|
1432
|
+
</script>
|