tink-harness 1.9.4 → 1.9.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tink",
3
3
  "description": "A small harness layer for Claude Code and Codex.",
4
- "version": "1.9.4",
4
+ "version": "1.9.6",
5
5
  "author": {
6
6
  "name": "dotori"
7
7
  }
package/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ All notable changes to Tink are tracked here.
6
6
 
7
7
  No unreleased changes yet.
8
8
 
9
+ ## [1.9.6] - 2026-06-10
10
+
11
+ ### Added
12
+
13
+ - The harness map is now fully navigable: wheel zoom toward the cursor (0.4x–5x), drag to pan, +/− and reset controls, and double-click to return to the full view.
14
+ - Nodes render as 3D-style spheres with radial gradients, depth shadows on interactive nodes, and a vignette background for depth.
15
+ - Added a "How to read this map" card to the graph tab's right rail with plain-language explanations of circles, colors, lines, satellites, controls, and edge types.
16
+
17
+ ### Changed
18
+
19
+ - Reordered the graph right rail: reading guide → selected node → graph overview.
20
+ - Map help text now mentions zoom and drag controls.
21
+
22
+ ## [1.9.5] - 2026-06-10
23
+
24
+ ### Added
25
+
26
+ - Run timeline entries now show a colored outcome badge, a monospace timestamp, and harness chips instead of a comma list.
27
+ - The Activity tab gained a summary strip: total runs, run window, and completed/blocked/failed/recorded counts.
28
+ - Memory cards now show reference counts and referencing-harness chips derived from `uses_memory` graph edges.
29
+
30
+ ### Fixed
31
+
32
+ - The lifecycle generator now reads YAML-frontmatter `outcome:` values from run records, so completed runs are counted as successes instead of unknown.
33
+ - Removed a generic "blocked" word match that flagged runs as blocked when the word merely appeared in prose.
34
+
9
35
  ## [1.9.4] - 2026-06-10
10
36
 
11
37
  ### Added
package/VERSIONING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Versioning
2
2
 
3
- Current version: `1.9.4`
3
+ Current version: `1.9.6`
4
4
 
5
5
  Tink follows semver from `1.0.0` onward.
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tink-harness",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "Self-growing harnesses for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,10 +83,13 @@ function extractRefs(text, refs) {
83
83
  function parseRun(filePath, harnessIds, ruleRefs, memoryRefs) {
84
84
  const text = fs.readFileSync(filePath, 'utf8');
85
85
  const statusMatch = text.match(/^Status:\s*([A-Za-z_-]+)/mi);
86
- const status = statusMatch ? statusMatch[1].toLowerCase() : 'unknown';
86
+ const outcomeMatch = text.match(/^outcome:\s*"?([A-Za-z_-]+)"?/mi);
87
+ const status = statusMatch
88
+ ? statusMatch[1].toLowerCase()
89
+ : (outcomeMatch ? outcomeMatch[1].toLowerCase() : 'unknown');
87
90
  const harnesses = extractSelectedHarnesses(text, harnessIds);
88
91
  const failed = /check_failed|failed check|required check failed|verification failed/i.test(text);
89
- const blocked = status === 'blocked' || /check_blocked|blocked/i.test(text);
92
+ const blocked = status === 'blocked' || /check_blocked/i.test(text);
90
93
  const completed = status === 'completed' || status === 'pass' || /npm test passed|verification passed/i.test(text);
91
94
  return {
92
95
  path: filePath,
@@ -48,7 +48,7 @@ const COPY = {
48
48
  heroText: 'Every visible Tink run, rule, memory reference, and harness relationship mapped into one local dashboard. This report only prepares suggestions and never edits reusable state.',
49
49
  generated: 'GENERATED',
50
50
  harnessMap: 'HARNESS MAP',
51
- mapHelp: 'Harnesses, rules, memory, and stages are mapped from visible Tink records. Click a node to inspect it.',
51
+ mapHelp: 'Harnesses, rules, memory, and stages are mapped from visible Tink records. Scroll to zoom, drag to move, click a node to inspect it.',
52
52
  graphControls: 'Graph controls',
53
53
  full: 'Full',
54
54
  core: 'Core',
@@ -143,6 +143,33 @@ const COPY = {
143
143
  historyHelp: 'Approved reusable-state changes from the maintenance ledger, newest first.',
144
144
  historyEmpty: 'No ledger history yet.',
145
145
  sortNote: 'Sorted by usage',
146
+ runWindow: 'Run window',
147
+ totalRuns: 'Runs',
148
+ refCount: 'References',
149
+ zoomIn: 'Zoom in',
150
+ zoomOut: 'Zoom out',
151
+ zoomReset: 'Reset view',
152
+ mapGuideEyebrow: 'HOW TO READ',
153
+ mapGuideTitle: 'What is this map?',
154
+ mapGuideText: 'Each circle is something Tink knows about: a harness, a rule it loads, or a memory file it reads. The map is drawn only from visible local records.',
155
+ guideItems: [
156
+ ['Big circle', 'The more a harness is used, the bigger its circle.'],
157
+ ['Color', 'Blue circles are harnesses; gray ones are rules, memory, and stages.'],
158
+ ['Line', 'A line means "works together": a harness using a rule, reading memory, or leading to a next step.'],
159
+ ['Small dots', 'Tiny satellites around a harness are its usage, evidence, and score signals.']
160
+ ],
161
+ controlItems: [
162
+ ['Wheel / + −', 'Zoom in and out'],
163
+ ['Drag', 'Move around the map'],
164
+ ['Double-click', 'Back to full view'],
165
+ ['Click a circle', 'See its details below']
166
+ ],
167
+ relationTitle: 'What the lines mean',
168
+ relationItems: [
169
+ ['uses_rule', 'This harness loads that rule'],
170
+ ['uses_memory', 'This harness reads that memory file'],
171
+ ['sequence', 'This harness is usually followed by that step']
172
+ ],
146
173
  groups: [
147
174
  ['keep', 'Healthy harnesses', 'Ready to keep using'],
148
175
  ['weave', 'Weave candidates', 'Worth improving next'],
@@ -200,6 +227,33 @@ COPY.ko = {
200
227
  historyHelp: '유지보수 장부에 기록된 승인 변경 이력을 최신순으로 보여줍니다.',
201
228
  historyEmpty: '아직 장부 기록이 없습니다.',
202
229
  sortNote: '사용량 순 정렬',
230
+ runWindow: '기록 기간',
231
+ totalRuns: 'Run 수',
232
+ refCount: '참조 횟수',
233
+ zoomIn: '확대',
234
+ zoomOut: '축소',
235
+ zoomReset: '전체 보기',
236
+ mapGuideEyebrow: '지도 읽는 법',
237
+ mapGuideTitle: '이 지도는 무엇인가요?',
238
+ mapGuideText: '원 하나하나가 Tink가 알고 있는 것입니다: 하네스, 하네스가 쓰는 규칙, 읽는 메모리 파일. 로컬에 보이는 기록만으로 그려집니다.',
239
+ guideItems: [
240
+ ['큰 원', '하네스를 많이 쓸수록 원이 커집니다.'],
241
+ ['색상', '파란 원이 하네스이고, 회색 계열은 규칙·메모리·단계입니다.'],
242
+ ['선', '"함께 일한다"는 뜻입니다. 하네스가 규칙을 쓰거나, 메모리를 읽거나, 다음 단계로 이어질 때 선이 생깁니다.'],
243
+ ['작은 점', '하네스 주변의 작은 점들은 사용·근거·점수 신호입니다.']
244
+ ],
245
+ controlItems: [
246
+ ['휠 / + −', '확대·축소'],
247
+ ['드래그', '지도 이동'],
248
+ ['더블클릭', '전체 보기로 복귀'],
249
+ ['원 클릭', '아래에서 상세 보기']
250
+ ],
251
+ relationTitle: '선의 의미',
252
+ relationItems: [
253
+ ['uses_rule', '이 하네스가 그 규칙을 불러옵니다'],
254
+ ['uses_memory', '이 하네스가 그 메모리 파일을 읽습니다'],
255
+ ['sequence', '이 하네스 다음에 그 단계가 자주 이어집니다']
256
+ ],
203
257
  navLabel: '탐색',
204
258
  operator: '작업자',
205
259
  online: 'Tink 온라인',
@@ -208,7 +262,7 @@ COPY.ko = {
208
262
  heroText: '보이는 Tink run, rule, memory reference, harness 관계를 하나의 로컬 대시보드로 보여줍니다. 이 보고서는 제안만 준비하며 재사용 상태를 직접 수정하지 않습니다.',
209
263
  generated: '생성 시각',
210
264
  harnessMap: '하네스 지도',
211
- mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 그립니다. 노드를 클릭하면 자세히 볼 수 있습니다.',
265
+ mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 그립니다. 휠로 확대, 드래그로 이동, 노드를 클릭하면 자세히 볼 수 있습니다.',
212
266
  graphControls: '그래프 조작',
213
267
  full: '전체',
214
268
  core: '핵심',
@@ -632,13 +686,34 @@ function renderGraphCanvas(summary, copy) {
632
686
  <h2 id="map-title">${escapeHtml(mapTitle)}</h2>
633
687
  <p>${escapeHtml(copy.mapHelp)}</p>
634
688
  </div>
635
- <div class="map-controls" aria-label="${escapeAttr(copy.graphControls)}">
636
- <button class="active" type="button" data-mode="full" aria-pressed="true">${escapeHtml(copy.full)}</button>
637
- <button type="button" data-mode="core" aria-pressed="false">${escapeHtml(copy.core)}</button>
689
+ <div class="map-controls-row">
690
+ <div class="map-controls" aria-label="${escapeAttr(copy.graphControls)}">
691
+ <button class="active" type="button" data-mode="full" aria-pressed="true">${escapeHtml(copy.full)}</button>
692
+ <button type="button" data-mode="core" aria-pressed="false">${escapeHtml(copy.core)}</button>
693
+ </div>
694
+ <div class="map-controls" aria-label="zoom">
695
+ <button type="button" data-zoom="in" aria-label="${escapeAttr(copy.zoomIn || 'Zoom in')}" title="${escapeAttr(copy.zoomIn || 'Zoom in')}">+</button>
696
+ <button type="button" data-zoom="out" aria-label="${escapeAttr(copy.zoomOut || 'Zoom out')}" title="${escapeAttr(copy.zoomOut || 'Zoom out')}">−</button>
697
+ <button type="button" data-zoom="reset" title="${escapeAttr(copy.zoomReset || 'Reset view')}">${escapeHtml(copy.zoomReset || 'Reset')}</button>
698
+ </div>
638
699
  </div>
639
700
  </div>
640
701
  <svg class="graph-canvas" viewBox="0 0 1090 680" role="img" aria-label="Harness health graph">
641
- <rect width="1090" height="680" fill="var(--bg-card)"/>
702
+ <defs>
703
+ <radialGradient id="graph-bg-grad" cx="50%" cy="42%" r="75%">
704
+ <stop offset="0%" style="stop-color: #16181D"/>
705
+ <stop offset="100%" style="stop-color: #0C0D10"/>
706
+ </radialGradient>
707
+ ${Object.entries(TYPE_COLORS).map(([type, color]) => `
708
+ <radialGradient id="node-grad-${escapeAttr(type)}" cx="32%" cy="28%" r="78%">
709
+ <stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.42"/>
710
+ <stop offset="38%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.98"/>
711
+ <stop offset="100%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.78"/>
712
+ </radialGradient>
713
+ `).join('')}
714
+ </defs>
715
+ <rect class="graph-bg" width="1090" height="680" fill="url(#graph-bg-grad)"/>
716
+ <g id="graph-viewport">
642
717
  <g class="edges">
643
718
  ${edges.map((edge, index) => `
644
719
  <line
@@ -680,8 +755,8 @@ function renderGraphCanvas(summary, copy) {
680
755
  cx="${node.x.toFixed(1)}"
681
756
  cy="${node.y.toFixed(1)}"
682
757
  r="${node.radius.toFixed(1)}"
683
- fill="${escapeAttr(node.color)}"
684
- fill-opacity="${node.type === 'harness' ? '0.96' : '0.82'}"
758
+ fill="url(#node-grad-${escapeAttr(TYPE_COLORS[node.type] ? node.type : 'unknown')})"
759
+ fill-opacity="${node.type === 'harness' ? '1' : '0.85'}"
685
760
  stroke="${escapeAttr('var(--text-secondary)')}"
686
761
  stroke-opacity="${node.glow ? '0.9' : '0.18'}"
687
762
  stroke-width="${node.glow ? '1.8' : '0.8'}"
@@ -697,6 +772,7 @@ function renderGraphCanvas(summary, copy) {
697
772
  <text x="${(node.x + node.radius + 7).toFixed(1)}" y="${(node.y + 4).toFixed(1)}">${escapeHtml(node.label)}</text>
698
773
  `).join('')}
699
774
  </g>
775
+ </g>
700
776
  </svg>
701
777
  <div class="graph-tooltip" id="graph-tooltip" role="status" aria-live="polite"></div>
702
778
  <div class="map-caption">
@@ -767,15 +843,26 @@ function dedupeTimelineEvents(events = [], harnessIds = null, limit = 8) {
767
843
  }
768
844
 
769
845
  function renderTimelineItems(items, copy) {
770
- return items.map((event) => `
846
+ return items.map((event) => {
847
+ const outcome = timelineOutcomeClass(event);
848
+ const chips = (event.harnesses || []).slice(0, 6);
849
+ return `
771
850
  <li>
772
- <span class="dot ${escapeHtml(timelineOutcomeClass(event))}"></span>
851
+ <span class="dot ${escapeHtml(outcome)}"></span>
773
852
  <div>
774
- <strong>${escapeHtml(timelineOutcomeLabel(event, copy))} - ${escapeHtml(shortDate(event.date))}</strong>
775
- <p>${escapeHtml(formatHarnessList(event.harnesses) || copy.noHarnessRecorded)}</p>
853
+ <div class="run-row">
854
+ <span class="run-badge ${escapeHtml(outcome)}">${escapeHtml(timelineOutcomeLabel(event, copy))}</span>
855
+ <time>${escapeHtml(shortDate(event.date))}</time>
856
+ </div>
857
+ <div class="run-chips">
858
+ ${chips.length
859
+ ? chips.map((id) => `<span class="co-chip">${escapeHtml(id)}</span>`).join('')
860
+ : `<span class="run-empty">${escapeHtml(copy.noHarnessRecorded)}</span>`}
861
+ </div>
776
862
  </div>
777
863
  </li>
778
- `).join('') || `<li><span class="dot observe"></span><div><strong>${escapeHtml(copy.noRunEvents)}</strong><p>${escapeHtml(copy.runRecordsWillAppear)}</p></div></li>`;
864
+ `;
865
+ }).join('') || `<li><span class="dot observe"></span><div><strong>${escapeHtml(copy.noRunEvents)}</strong><p>${escapeHtml(copy.runRecordsWillAppear)}</p></div></li>`;
779
866
  }
780
867
 
781
868
  function renderTimeline(events = [], copy, harnessIds = null, options = {}) {
@@ -991,6 +1078,39 @@ function renderGraphOverview(graph = {}, copy) {
991
1078
  `;
992
1079
  }
993
1080
 
1081
+ function renderMapGuide(copy) {
1082
+ const guideItems = Array.isArray(copy.guideItems) ? copy.guideItems : [];
1083
+ const controlItems = Array.isArray(copy.controlItems) ? copy.controlItems : [];
1084
+ const relationItems = Array.isArray(copy.relationItems) ? copy.relationItems : [];
1085
+ return `
1086
+ <section class="insight-card map-guide">
1087
+ <div class="panel-title">
1088
+ <p class="eyebrow">${escapeHtml(copy.mapGuideEyebrow || 'HOW TO READ')}</p>
1089
+ <h2>${escapeHtml(copy.mapGuideTitle || 'What is this map?')}</h2>
1090
+ <p>${escapeHtml(copy.mapGuideText || '')}</p>
1091
+ </div>
1092
+ <ul class="guide-list">
1093
+ ${guideItems.map(([term, text]) => `
1094
+ <li><strong>${escapeHtml(term)}</strong><span>${escapeHtml(text)}</span></li>
1095
+ `).join('')}
1096
+ </ul>
1097
+ <ul class="guide-list guide-controls">
1098
+ ${controlItems.map(([key, text]) => `
1099
+ <li><kbd>${escapeHtml(key)}</kbd><span>${escapeHtml(text)}</span></li>
1100
+ `).join('')}
1101
+ </ul>
1102
+ ${relationItems.length ? `
1103
+ <p class="detail-label">${escapeHtml(copy.relationTitle || 'What the lines mean')}</p>
1104
+ <ul class="guide-list guide-relations">
1105
+ ${relationItems.map(([type, text]) => `
1106
+ <li><code>${escapeHtml(type)}</code><span>${escapeHtml(text)}</span></li>
1107
+ `).join('')}
1108
+ </ul>
1109
+ ` : ''}
1110
+ </section>
1111
+ `;
1112
+ }
1113
+
994
1114
  function renderRoutingCard(copy) {
995
1115
  const rules = Array.isArray(copy.routeRules) ? copy.routeRules : [];
996
1116
  return `
@@ -1070,19 +1190,26 @@ function renderHomePage(summary, copy, harnesses, harnessIds) {
1070
1190
  function renderMemoryPage(summary, copy) {
1071
1191
  const harnesses = getVisibleHarnesses(Array.isArray(summary.harnesses) ? summary.harnesses : []);
1072
1192
  const refs = new Map();
1193
+ const ensureRef = (key) => {
1194
+ if (!refs.has(key)) refs.set(key, { users: new Set(), count: 0 });
1195
+ return refs.get(key);
1196
+ };
1073
1197
  for (const harness of harnesses) {
1074
1198
  for (const ref of harness.signals?.memory_refs || []) {
1075
- const key = normalizePath(ref);
1076
- if (!refs.has(key)) refs.set(key, new Set());
1077
- refs.get(key).add(harness.id);
1199
+ ensureRef(normalizePath(ref)).users.add(harness.id);
1078
1200
  }
1079
1201
  }
1202
+ for (const edge of getRenderableEdges(summary.graph?.edges || [])) {
1203
+ if (edge.type !== 'uses_memory') continue;
1204
+ const entry = ensureRef(normalizePath(String(edge.target).replace(/^memory:/, '')));
1205
+ entry.users.add(shortLabel(edge.source));
1206
+ entry.count += Number(edge.count || 1);
1207
+ }
1080
1208
  for (const node of getRenderableNodes(summary.graph?.nodes || [])) {
1081
1209
  if (node.type !== 'memory') continue;
1082
- const key = normalizePath(shortLabel(node.id));
1083
- if (!refs.has(key)) refs.set(key, new Set());
1210
+ ensureRef(normalizePath(String(node.id).replace(/^memory:/, '')));
1084
1211
  }
1085
- const entries = [...refs.entries()].sort(([a], [b]) => a.localeCompare(b));
1212
+ const entries = [...refs.entries()].sort(([, a], [, b]) => b.count - a.count || b.users.size - a.users.size);
1086
1213
  return `
1087
1214
  <section class="page-head">
1088
1215
  <p class="eyebrow">${escapeHtml(copy.memoryEyebrow || 'MEMORY')}</p>
@@ -1091,15 +1218,21 @@ function renderMemoryPage(summary, copy) {
1091
1218
  </section>
1092
1219
  ${entries.length ? `
1093
1220
  <div class="memory-grid">
1094
- ${entries.map(([file, users]) => `
1221
+ ${entries.map(([file, info]) => `
1095
1222
  <article class="insight-card memory-card">
1096
1223
  <h3><code>${escapeHtml(file)}</code></h3>
1097
1224
  <dl>
1098
1225
  <div>
1099
- <dt>${escapeHtml(copy.referencedBy || 'Referenced by')}</dt>
1100
- <dd>${[...users].length ? [...users].sort().map((id) => escapeHtml(id)).join(', ') : escapeHtml(copy.none || 'None')}</dd>
1226
+ <dt>${escapeHtml(copy.refCount || 'References')}</dt>
1227
+ <dd>${escapeHtml(formatNumber(Math.max(info.count, info.users.size)))}</dd>
1101
1228
  </div>
1102
1229
  </dl>
1230
+ <p class="detail-label">${escapeHtml(copy.referencedBy || 'Referenced by')}</p>
1231
+ <div class="co-used-chips">
1232
+ ${[...info.users].length
1233
+ ? [...info.users].sort().map((id) => `<span class="co-chip">${escapeHtml(id)}</span>`).join('')
1234
+ : `<span class="run-empty">${escapeHtml(copy.none || 'None')}</span>`}
1235
+ </div>
1103
1236
  </article>
1104
1237
  `).join('')}
1105
1238
  </div>
@@ -1109,12 +1242,35 @@ function renderMemoryPage(summary, copy) {
1109
1242
 
1110
1243
  function renderActivityPage(summary, copy, harnessIds) {
1111
1244
  const items = dedupeTimelineEvents(summary.timeline || [], harnessIds, 30);
1245
+ const all = Array.isArray(summary.timeline) ? summary.timeline : [];
1246
+ const counts = { success: 0, blocked: 0, failed: 0, recorded: 0 };
1247
+ for (const event of all) {
1248
+ const key = timelineOutcomeClass(event);
1249
+ counts[key in counts ? key : 'recorded'] += 1;
1250
+ }
1251
+ const window = summary.run_window || {};
1252
+ const windowText = window.from && window.to
1253
+ ? `${shortDate(window.from)} ~ ${shortDate(window.to)}`
1254
+ : renderCopyValue('', copy);
1255
+ const summaryCells = [
1256
+ [copy.totalRuns || 'Runs', formatNumber(window.run_count || all.length)],
1257
+ [copy.runWindow || 'Run window', windowText],
1258
+ [copy.timelineCompleted || 'Completed', formatNumber(counts.success)],
1259
+ [copy.timelineBlocked || 'Blocked', formatNumber(counts.blocked)],
1260
+ [copy.timelineFailed || 'Failed', formatNumber(counts.failed)],
1261
+ [copy.timelineRecorded || 'Recorded', formatNumber(counts.recorded)]
1262
+ ];
1112
1263
  return `
1113
1264
  <section class="page-head">
1114
1265
  <p class="eyebrow">${escapeHtml(copy.activityEyebrow || 'ACTIVITY')}</p>
1115
1266
  <h1>${escapeHtml(copy.activityTitle || 'Run activity')}</h1>
1116
1267
  <p>${escapeHtml(copy.activityHelp || '')}</p>
1117
1268
  </section>
1269
+ <section class="activity-summary">
1270
+ ${summaryCells.map(([label, value]) => `
1271
+ <article><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></article>
1272
+ `).join('')}
1273
+ </section>
1118
1274
  <section class="timeline activity-feed">
1119
1275
  <ol>
1120
1276
  ${renderTimelineItems(items, copy)}
@@ -1311,6 +1467,76 @@ function renderScript(harnesses, copy) {
1311
1467
  document.querySelectorAll('[data-mode]').forEach((button) => {
1312
1468
  button.addEventListener('click', () => applyMode(button.dataset.mode));
1313
1469
  });
1470
+ const graphSvg = document.querySelector('.graph-canvas');
1471
+ const graphViewport = document.getElementById('graph-viewport');
1472
+ if (graphSvg && graphViewport) {
1473
+ const view = { x: 0, y: 0, k: 1 };
1474
+ graphViewport.style.transformOrigin = '0 0';
1475
+ const applyView = () => {
1476
+ graphViewport.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.k + ')';
1477
+ };
1478
+ const svgPoint = (event) => {
1479
+ const pt = graphSvg.createSVGPoint();
1480
+ pt.x = event.clientX;
1481
+ pt.y = event.clientY;
1482
+ return pt.matrixTransform(graphSvg.getScreenCTM().inverse());
1483
+ };
1484
+ const zoomAt = (factor, cx, cy) => {
1485
+ const k = Math.min(5, Math.max(0.4, view.k * factor));
1486
+ const real = k / view.k;
1487
+ view.x = cx - (cx - view.x) * real;
1488
+ view.y = cy - (cy - view.y) * real;
1489
+ view.k = k;
1490
+ applyView();
1491
+ };
1492
+ const resetView = () => {
1493
+ graphViewport.classList.add('is-resetting');
1494
+ view.x = 0; view.y = 0; view.k = 1;
1495
+ applyView();
1496
+ setTimeout(() => graphViewport.classList.remove('is-resetting'), 360);
1497
+ };
1498
+ graphSvg.addEventListener('wheel', (event) => {
1499
+ event.preventDefault();
1500
+ const point = svgPoint(event);
1501
+ zoomAt(event.deltaY < 0 ? 1.15 : 1 / 1.15, point.x, point.y);
1502
+ }, { passive: false });
1503
+ let panState = null;
1504
+ graphSvg.addEventListener('pointerdown', (event) => {
1505
+ if (event.button !== 0) return;
1506
+ panState = { x: event.clientX, y: event.clientY, moved: false };
1507
+ graphSvg.setPointerCapture(event.pointerId);
1508
+ });
1509
+ graphSvg.addEventListener('pointermove', (event) => {
1510
+ if (!panState) return;
1511
+ const dx = event.clientX - panState.x;
1512
+ const dy = event.clientY - panState.y;
1513
+ if (!panState.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
1514
+ panState.moved = true;
1515
+ graphSvg.classList.add('is-panning');
1516
+ const scale = 1090 / graphSvg.clientWidth;
1517
+ view.x += dx * scale;
1518
+ view.y += dy * scale;
1519
+ panState.x = event.clientX;
1520
+ panState.y = event.clientY;
1521
+ applyView();
1522
+ });
1523
+ const endPan = () => {
1524
+ panState = null;
1525
+ graphSvg.classList.remove('is-panning');
1526
+ };
1527
+ graphSvg.addEventListener('pointerup', endPan);
1528
+ graphSvg.addEventListener('pointercancel', endPan);
1529
+ graphSvg.addEventListener('dblclick', (event) => {
1530
+ event.preventDefault();
1531
+ resetView();
1532
+ });
1533
+ document.querySelectorAll('[data-zoom]').forEach((button) => {
1534
+ button.addEventListener('click', () => {
1535
+ if (button.dataset.zoom === 'reset') return resetView();
1536
+ zoomAt(button.dataset.zoom === 'in' ? 1.3 : 1 / 1.3, 545, 340);
1537
+ });
1538
+ });
1539
+ }
1314
1540
  const VALID_TABS = ['home', 'harnesses', 'memory', 'graph', 'activity'];
1315
1541
  const navLinks = Array.from(document.querySelectorAll('.nav a[data-tab]'));
1316
1542
  const pages = Array.from(document.querySelectorAll('.page'));
@@ -1967,6 +2193,83 @@ function renderStyles() {
1967
2193
  transition: opacity 160ms ease, transform 160ms ease;
1968
2194
  }
1969
2195
 
2196
+ .map-controls-row {
2197
+ display: flex;
2198
+ gap: var(--space-2);
2199
+ align-items: center;
2200
+ flex-wrap: wrap;
2201
+ }
2202
+
2203
+ .graph-canvas {
2204
+ cursor: grab;
2205
+ touch-action: none;
2206
+ }
2207
+
2208
+ .graph-canvas.is-panning { cursor: grabbing; }
2209
+
2210
+ #graph-viewport {
2211
+ will-change: transform;
2212
+ }
2213
+
2214
+ #graph-viewport.is-resetting {
2215
+ transition: transform 340ms cubic-bezier(0.2, 0.8, 0.2, 1);
2216
+ }
2217
+
2218
+ .graph-node.is-interactive {
2219
+ filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55));
2220
+ }
2221
+
2222
+ .graph-node.is-interactive:hover,
2223
+ .graph-node.is-interactive:focus-visible {
2224
+ filter: drop-shadow(0 9px 14px rgba(0, 0, 0, 0.65));
2225
+ }
2226
+
2227
+ .map-guide .panel-title p:last-child {
2228
+ font-size: 12px;
2229
+ line-height: 1.5;
2230
+ }
2231
+
2232
+ .guide-list {
2233
+ margin: var(--space-3) 0 0;
2234
+ padding: 0;
2235
+ list-style: none;
2236
+ display: grid;
2237
+ gap: var(--space-2);
2238
+ }
2239
+
2240
+ .guide-list li {
2241
+ display: grid;
2242
+ grid-template-columns: 76px 1fr;
2243
+ gap: var(--space-2);
2244
+ align-items: start;
2245
+ font-size: 12px;
2246
+ line-height: 1.45;
2247
+ color: var(--text-secondary);
2248
+ }
2249
+
2250
+ .guide-list strong {
2251
+ color: var(--text-primary);
2252
+ font-size: 12px;
2253
+ font-weight: 500;
2254
+ }
2255
+
2256
+ .guide-list kbd {
2257
+ font-family: var(--font-mono);
2258
+ font-size: 10px;
2259
+ color: var(--accent-text);
2260
+ border: 1px solid var(--border-default);
2261
+ border-radius: var(--radius-sm);
2262
+ background: var(--bg-hover);
2263
+ padding: 2px 5px;
2264
+ text-align: center;
2265
+ white-space: nowrap;
2266
+ }
2267
+
2268
+ .guide-controls { border-top: 1px solid var(--border-default); padding-top: var(--space-3); }
2269
+
2270
+ .guide-relations li { grid-template-columns: 96px 1fr; }
2271
+ .guide-relations code { font-size: 10px; }
2272
+
1970
2273
  .map-legend {
1971
2274
  display: flex;
1972
2275
  gap: var(--space-3);
@@ -2603,6 +2906,79 @@ function renderStyles() {
2603
2906
  .activity-feed { padding: var(--space-4); }
2604
2907
  .activity-feed ol { gap: var(--space-3); }
2605
2908
 
2909
+ .run-row {
2910
+ display: flex;
2911
+ align-items: center;
2912
+ gap: var(--space-2);
2913
+ }
2914
+
2915
+ .run-row time {
2916
+ color: var(--text-secondary);
2917
+ font-size: 11px;
2918
+ font-family: var(--font-mono);
2919
+ }
2920
+
2921
+ .run-badge {
2922
+ display: inline-block;
2923
+ font-size: 10px;
2924
+ font-weight: 500;
2925
+ text-transform: uppercase;
2926
+ letter-spacing: 0.05em;
2927
+ border: 1px solid var(--border-default);
2928
+ border-radius: var(--radius-sm);
2929
+ padding: 2px 6px;
2930
+ color: var(--text-secondary);
2931
+ }
2932
+
2933
+ .run-badge.success { color: var(--success); border-color: var(--success-dim); background: var(--success-dim); }
2934
+ .run-badge.blocked { color: var(--warning); border-color: var(--warning-dim); background: var(--warning-dim); }
2935
+ .run-badge.failed { color: var(--danger); border-color: var(--danger-dim); background: var(--danger-dim); }
2936
+ .run-badge.recorded { background: var(--bg-hover); }
2937
+
2938
+ .run-chips {
2939
+ display: flex;
2940
+ flex-wrap: wrap;
2941
+ gap: 4px;
2942
+ margin-top: 5px;
2943
+ }
2944
+
2945
+ .run-empty {
2946
+ color: var(--text-muted);
2947
+ font-size: 11px;
2948
+ }
2949
+
2950
+ .activity-summary {
2951
+ display: grid;
2952
+ grid-template-columns: repeat(6, minmax(0, 1fr));
2953
+ gap: var(--space-2);
2954
+ margin-bottom: var(--space-3);
2955
+ }
2956
+
2957
+ .activity-summary article {
2958
+ border: 1px solid var(--border-default);
2959
+ border-radius: var(--radius-lg);
2960
+ background: var(--bg-card);
2961
+ padding: var(--space-3);
2962
+ }
2963
+
2964
+ .activity-summary span {
2965
+ display: block;
2966
+ color: var(--text-secondary);
2967
+ font-size: 11px;
2968
+ letter-spacing: 0.06em;
2969
+ text-transform: uppercase;
2970
+ }
2971
+
2972
+ .activity-summary strong {
2973
+ display: block;
2974
+ margin-top: var(--space-1);
2975
+ font-size: 16px;
2976
+ font-family: var(--font-mono);
2977
+ font-weight: 600;
2978
+ letter-spacing: -0.02em;
2979
+ overflow-wrap: anywhere;
2980
+ }
2981
+
2606
2982
  @media (max-width: 1180px) {
2607
2983
  .app-shell { grid-template-columns: 200px minmax(0, 1fr); }
2608
2984
  .right-rail {
@@ -2618,6 +2994,7 @@ function renderStyles() {
2618
2994
  .stats-grid { grid-template-columns: 1fr; }
2619
2995
  .home-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2620
2996
  .home-columns { grid-template-columns: 1fr; }
2997
+ .activity-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2621
2998
  }
2622
2999
 
2623
3000
  @media (max-width: 760px) {
@@ -2639,6 +3016,8 @@ function renderStyles() {
2639
3016
  .stats-grid { grid-template-columns: 1fr; }
2640
3017
  .home-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2641
3018
  .memory-grid { grid-template-columns: 1fr; }
3019
+ .activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
3020
+ .harness-grid { grid-template-columns: 1fr; }
2642
3021
  .map-head { display: block; }
2643
3022
  .map-controls { margin-top: var(--space-2); width: max-content; }
2644
3023
  .right-rail { padding: var(--space-4); }
@@ -2721,9 +3100,10 @@ function renderReport(summary) {
2721
3100
  <aside class="right-rail" aria-label="Insights">
2722
3101
  <div data-rail="home harnesses memory activity">${renderStats(summary, copy)}</div>
2723
3102
  <div data-rail="home harnesses">${renderConfidence(summary, copy)}</div>
2724
- <div data-rail="graph">${renderGraphOverview(summary.graph || {}, copy)}</div>
2725
- <div data-rail="home harnesses">${renderImportantHarnesses(harnesses, copy)}</div>
3103
+ <div data-rail="graph">${renderMapGuide(copy)}</div>
2726
3104
  <div data-rail="graph">${renderSelectedPanel(harnesses, copy)}</div>
3105
+ <div data-rail="home harnesses">${renderImportantHarnesses(harnesses, copy)}</div>
3106
+ <div data-rail="graph">${renderGraphOverview(summary.graph || {}, copy)}</div>
2727
3107
  </aside>
2728
3108
  </div>
2729
3109
  ${renderContractMetadata(copy)}