tink-harness 1.9.7 → 1.9.9

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.7",
4
+ "version": "1.9.9",
5
5
  "author": {
6
6
  "name": "dotori"
7
7
  }
package/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ All notable changes to Tink are tracked here.
6
6
 
7
7
  No unreleased changes yet.
8
8
 
9
+ ## [1.9.9] - 2026-06-11
10
+
11
+ ### Changed
12
+
13
+ - Rebuilt the harness map as a real Three.js WebGL scene: GPU-rendered 70k-particle spiral galaxy, nebulae, and starfield (no more SVG lag), with slow 3D auto-orbit and OrbitControls (drag to rotate, scroll to zoom, double-click to reset).
14
+ - Nodes are now textured 3D planets that rotate on their own axis; every node carries a screen-projected label and distinct type colors (harness blue, rule violet, memory teal, stage amber), so the map stays readable as a harness map.
15
+ - Neural pulses now travel along 3D edges; selection focus-dims unrelated planets/edges/labels, and all SVG-era satellites, orbit rings, and SMIL pulses were removed.
16
+
17
+ ### Added
18
+
19
+ - Offline fallback message when the three.js CDN is unreachable; prefers-reduced-motion disables auto-rotation, self-rotation, and pulses.
20
+
21
+ ## [1.9.8] - 2026-06-11
22
+
23
+ ### Added
24
+
25
+ - Galaxy-styled harness map background: a slowly rotating 4-arm spiral of 760 particles (pink-to-cyan, screen-blended), 7 colored nebula glows, a breathing core glow, and a deeper space gradient — inspired by a Three.js galaxy reference while keeping the SVG map fully interactive.
26
+ - Neural signal pulses: glowing dots travel along every edge from source to target (3.2-7.4s, staggered), so connections read like firing synapses.
27
+
28
+ ### Changed
29
+
30
+ - Signal pulses follow the map state: they hide with core mode/filters, dim in focus mode, and stay bright only on edges related to the selected node. Clicking the galaxy background clears the selection. prefers-reduced-motion disables rotation and pulses.
31
+
9
32
  ## [1.9.7] - 2026-06-10
10
33
 
11
34
  ### Added
package/VERSIONING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Versioning
2
2
 
3
- Current version: `1.9.7`
3
+ Current version: `1.9.9`
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.7",
3
+ "version": "1.9.9",
4
4
  "description": "Self-growing harnesses for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,7 +48,8 @@ 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. Scroll to zoom, drag to move, click a node to inspect it.',
51
+ mapHelp: 'Harnesses, rules, memory, and stages are mapped from visible Tink records in 3D. Drag to orbit, scroll to zoom, click a planet to inspect it.',
52
+ graph3dOffline: 'The 3D map needs an internet connection to load three.js. Reconnect and refresh this report.',
52
53
  graphControls: 'Graph controls',
53
54
  full: 'Full',
54
55
  core: 'Core',
@@ -262,7 +263,8 @@ COPY.ko = {
262
263
  heroText: '보이는 Tink run, rule, memory reference, harness 관계를 하나의 로컬 대시보드로 보여줍니다. 이 보고서는 제안만 준비하며 재사용 상태를 직접 수정하지 않습니다.',
263
264
  generated: '생성 시각',
264
265
  harnessMap: '하네스 지도',
265
- mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 그립니다. 휠로 확대, 드래그로 이동, 노드를 클릭하면 자세히 볼 수 있습니다.',
266
+ mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 3D로 그립니다. 드래그로 회전, 휠로 확대, 행성을 클릭하면 자세히 볼 수 있습니다.',
267
+ graph3dOffline: '3D 지도를 불러오려면 인터넷 연결이 필요합니다 (three.js CDN). 연결 후 보고서를 새로고침하세요.',
266
268
  graphControls: '그래프 조작',
267
269
  full: '전체',
268
270
  core: '핵심',
@@ -618,32 +620,19 @@ function buildGraphLayout(summary) {
618
620
  glow: score >= 50 || radius >= 20
619
621
  };
620
622
  });
621
- const orbitSystems = [];
622
- for (const node of positioned.filter((item) => item.type === 'harness')) {
623
- const harnessId = shortLabel(node.id);
624
- const harness = harnessById.get(harnessId);
625
- if (!harness) continue;
626
- const uses = Number(harness.signals?.uses || 0);
627
- const evidenceCount = (harness.evidence_handles || []).length;
628
- const factorCount = (harness.candidate_score?.factors || []).length;
629
- const seed = hashString(node.id);
630
- const rings = [
631
- { kind: 'signal', count: Math.min(10, Math.ceil(uses / 2)), distance: node.radius + 18, dotRadius: 2.4 },
632
- { kind: 'evidence', count: Math.min(6, evidenceCount), distance: node.radius + 29, dotRadius: 2.9 },
633
- { kind: 'score', count: Math.min(5, factorCount), distance: node.radius + 40, dotRadius: 3.2 }
634
- ]
635
- .filter((ring) => ring.count > 0)
636
- .map((ring, ringIndex) => ({
637
- ...ring,
638
- duration: (28 + ((seed >> (ringIndex * 3)) % 36)).toFixed(0),
639
- reverse: ringIndex % 2 === 1,
640
- phase: ((seed >> (ringIndex * 5)) % 628) / 100
641
- }));
642
- if (rings.length) {
643
- orbitSystems.push({ parentId: node.id, x: node.x, y: node.y, rings });
644
- }
645
- }
646
- const byId = new Map(positioned.map((node) => [node.id, node]));
623
+ const spatial = positioned.map((node) => {
624
+ const hash = hashString(`y3:${node.id}`);
625
+ const ySpread = node.type === 'harness' ? 7 : 11;
626
+ return {
627
+ ...node,
628
+ x3: Number(((node.x - 545) * 0.055).toFixed(2)),
629
+ y3: Number((((hash % 200) / 200 - 0.5) * 2 * ySpread).toFixed(2)),
630
+ z3: Number(((node.y - 340) * 0.055).toFixed(2)),
631
+ r3: Number((0.4 + node.radius * 0.085).toFixed(2)),
632
+ spin: Number((0.04 + ((hash >> 6) % 100) / 100 * 0.14).toFixed(3))
633
+ };
634
+ });
635
+ const byId = new Map(spatial.map((node) => [node.id, node]));
647
636
  const drawnEdges = getRenderableEdges(edges)
648
637
  .map((edge) => ({
649
638
  ...edge,
@@ -653,26 +642,32 @@ function buildGraphLayout(summary) {
653
642
  .filter((edge) => edge.sourceNode && edge.targetNode)
654
643
  .slice(0, 240);
655
644
 
656
- return { nodes: positioned, edges: drawnEdges, orbitSystems };
645
+ return { nodes: spatial, edges: drawnEdges };
657
646
  }
658
647
 
659
648
  function renderGraphCanvas(summary, copy) {
660
- const { nodes, edges, orbitSystems } = buildGraphLayout(summary);
661
- const strongest = nodes
662
- .filter((node) => node.type === 'harness')
663
- .sort((a, b) => Number(b.weight || 0) - Number(a.weight || 0))
664
- .slice(0, 8);
665
- const stars = Array.from({ length: 110 }, (_, index) => {
666
- const seed = hashString(`star:${index}`);
667
- return {
668
- x: seed % 1090,
669
- y: (seed >> 4) % 680,
670
- r: (0.5 + ((seed >> 8) % 12) / 10).toFixed(1),
671
- opacity: (0.1 + ((seed >> 6) % 45) / 100).toFixed(2),
672
- twinkle: index % 4 === 0,
673
- delay: seed % 5000
674
- };
675
- });
649
+ const { nodes, edges } = buildGraphLayout(summary);
650
+ const graphPayload = JSON.stringify({
651
+ nodes: nodes.map((node) => ({
652
+ id: node.id,
653
+ label: node.label,
654
+ type: TYPE_COLORS[node.type] ? node.type : 'unknown',
655
+ weight: Number(node.weight || 0),
656
+ recommendation: node.recommendation || '',
657
+ core: node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1,
658
+ x: node.x3,
659
+ y: node.y3,
660
+ z: node.z3,
661
+ r: node.r3,
662
+ spin: node.spin,
663
+ glow: Boolean(node.glow)
664
+ })),
665
+ edges: edges.map((edge) => ({
666
+ source: edge.source,
667
+ target: edge.target,
668
+ count: Number(edge.count || 1)
669
+ }))
670
+ }).replaceAll('<', '\\u003c');
676
671
  const mapTitle = copy.knowledgeGraph || copy.harnessMap || 'Harness map';
677
672
  const mapEyebrow = copy.harnessMap && copy.harnessMap !== mapTitle ? copy.harnessMap : '';
678
673
  return `
@@ -695,107 +690,10 @@ function renderGraphCanvas(summary, copy) {
695
690
  </div>
696
691
  </div>
697
692
  </div>
698
- <svg class="graph-canvas" viewBox="0 0 1090 680" role="img" aria-label="Harness health graph">
699
- <defs>
700
- <radialGradient id="graph-bg-grad" cx="50%" cy="42%" r="80%">
701
- <stop offset="0%" style="stop-color: #11141C"/>
702
- <stop offset="60%" style="stop-color: #0A0C12"/>
703
- <stop offset="100%" style="stop-color: #06070B"/>
704
- </radialGradient>
705
- ${Object.entries(TYPE_COLORS).map(([type, color]) => `
706
- <radialGradient id="node-grad-${escapeAttr(type)}" cx="32%" cy="28%" r="78%">
707
- <stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.42"/>
708
- <stop offset="38%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.98"/>
709
- <stop offset="100%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.78"/>
710
- </radialGradient>
711
- `).join('')}
712
- </defs>
713
- <rect class="graph-bg" width="1090" height="680" fill="url(#graph-bg-grad)"/>
714
- <g class="starfield" aria-hidden="true">
715
- ${stars.map((star) => `
716
- <circle cx="${star.x}" cy="${star.y}" r="${star.r}" fill="#FFFFFF" opacity="${star.opacity}"${star.twinkle ? ` class="star-twinkle" style="--twinkle-delay: ${star.delay}ms"` : ''}/>
717
- `).join('')}
718
- </g>
719
- <g id="graph-viewport">
720
- <g class="edges">
721
- ${edges.map((edge, index) => `
722
- <line
723
- class="graph-edge"
724
- style="--edge-delay: ${Math.min(index * 5, 850)}ms"
725
- data-source="${escapeAttr(edge.source)}"
726
- data-target="${escapeAttr(edge.target)}"
727
- x1="${edge.sourceNode.x.toFixed(1)}"
728
- y1="${edge.sourceNode.y.toFixed(1)}"
729
- x2="${edge.targetNode.x.toFixed(1)}"
730
- y2="${edge.targetNode.y.toFixed(1)}"
731
- stroke="${escapeAttr(edge.sourceNode.color)}"
732
- stroke-opacity="0.12"
733
- stroke-width="${clamp(Number(edge.count || 1), 1, 5)}"
734
- />
735
- `).join('')}
736
- </g>
737
- <g class="orbits" aria-hidden="true">
738
- ${orbitSystems.map((system) => `
739
- <g class="orbit-system" data-parent="${escapeAttr(system.parentId)}" transform="translate(${system.x.toFixed(1)} ${system.y.toFixed(1)})">
740
- ${system.rings.map((ring) => {
741
- const dots = Array.from({ length: ring.count }, (_, dotIndex) => {
742
- const angle = ring.phase + (dotIndex * Math.PI * 2) / ring.count;
743
- return `<circle class="orbit-dot" cx="${(Math.cos(angle) * ring.distance).toFixed(1)}" cy="${(Math.sin(angle) * ring.distance).toFixed(1)}" r="${ring.dotRadius}" fill="url(#node-grad-${escapeAttr(ring.kind)})"/>`;
744
- }).join('');
745
- return `
746
- <circle class="orbit-ring" cx="0" cy="0" r="${ring.distance.toFixed(1)}"/>
747
- <g class="orbit-spin${ring.reverse ? ' is-reverse' : ''}" style="--orbit-dur: ${ring.duration}s">${dots}</g>
748
- `;
749
- }).join('')}
750
- </g>
751
- `).join('')}
752
- </g>
753
- <g class="nodes">
754
- ${nodes.map((node, index) => {
755
- const seed = hashString(node.id);
756
- const floatDuration = (5 + (seed % 50) / 10).toFixed(1);
757
- const floatDelay = -(seed % 4000);
758
- const floatX = ((seed % 7) - 3).toFixed(1);
759
- const floatY = (((seed >> 3) % 7) - 3).toFixed(1);
760
- return `
761
- <g class="node-float" style="--float-dur: ${floatDuration}s; --float-delay: ${floatDelay}ms; --float-x: ${floatX}px; --float-y: ${floatY}px">
762
- ${node.type === 'harness' && node.radius >= 15 ? `
763
- <ellipse class="planet-ring" cx="${node.x.toFixed(1)}" cy="${node.y.toFixed(1)}" rx="${(node.radius * 1.75).toFixed(1)}" ry="${(node.radius * 0.5).toFixed(1)}" transform="rotate(-16 ${node.x.toFixed(1)} ${node.y.toFixed(1)})"/>
764
- ` : ''}
765
- <circle
766
- class="graph-node ${node.type === 'harness' ? 'is-interactive' : ''}"
767
- style="--enter-delay: ${Math.min(index * 9, 1100)}ms"
768
- tabindex="${node.type === 'harness' ? '0' : '-1'}"
769
- role="${node.type === 'harness' ? 'button' : 'presentation'}"
770
- aria-label="${escapeAttr(`${copy.tooltipPrefix}: ${node.label}`)}"
771
- data-node-id="${escapeAttr(node.id)}"
772
- data-node-type="${escapeAttr(node.type)}"
773
- data-node-label="${escapeAttr(node.label)}"
774
- data-node-weight="${escapeAttr(node.weight || 0)}"
775
- data-core="${node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1 ? 'true' : 'false'}"
776
- data-recommendation="${escapeAttr(node.recommendation || '')}"
777
- cx="${node.x.toFixed(1)}"
778
- cy="${node.y.toFixed(1)}"
779
- r="${node.radius.toFixed(1)}"
780
- fill="url(#node-grad-${escapeAttr(TYPE_COLORS[node.type] ? node.type : 'unknown')})"
781
- fill-opacity="${node.type === 'harness' ? '1' : '0.85'}"
782
- stroke="${escapeAttr('var(--text-secondary)')}"
783
- stroke-opacity="${node.glow ? '0.9' : '0.18'}"
784
- stroke-width="${node.glow ? '1.8' : '0.8'}"
785
- >
786
- <title>${escapeHtml(node.id)}</title>
787
- </circle>
788
- </g>
789
- `;
790
- }).join('')}
791
- </g>
792
- <g class="labels">
793
- ${strongest.map((node) => `
794
- <text x="${(node.x + node.radius + 7).toFixed(1)}" y="${(node.y + 4).toFixed(1)}">${escapeHtml(node.label)}</text>
795
- `).join('')}
796
- </g>
797
- </g>
798
- </svg>
693
+ <div class="graph-3d" id="graph-3d" role="img" aria-label="Harness health graph">
694
+ <p class="graph-3d-fallback" id="graph-3d-fallback" hidden>${escapeHtml(copy.graph3dOffline || 'The 3D map needs an internet connection to load three.js.')}</p>
695
+ </div>
696
+ <script type="application/json" id="graph-data">${graphPayload}</script>
799
697
  <div class="graph-tooltip" id="graph-tooltip" role="status" aria-live="polite"></div>
800
698
  <div class="map-caption">
801
699
  <span id="graph-status">${escapeHtml(copy.showingAll)}</span>
@@ -803,8 +701,8 @@ function renderGraphCanvas(summary, copy) {
803
701
  <span>${escapeHtml(copy.linesRelations)}</span>
804
702
  </div>
805
703
  <div class="map-legend" aria-label="${escapeAttr(copy.nodeTypes || 'Node types')}">
806
- ${['harness', 'rule', 'memory', 'stage', 'signal', 'evidence', 'score'].map((type) => `
807
- <span class="legend-chip"><i style="background: ${escapeAttr(TYPE_COLORS[type] || TYPE_COLORS.unknown)}"></i>${escapeHtml(type)}</span>
704
+ ${[['harness', 'var(--accent)'], ['rule', '#9B8CFF'], ['memory', '#4EC9B0'], ['stage', '#D7A65A']].map(([type, color]) => `
705
+ <span class="legend-chip"><i style="background: ${escapeAttr(color)}"></i>${escapeHtml(type)}</span>
808
706
  `).join('')}
809
707
  </div>
810
708
  </section>
@@ -1350,11 +1248,7 @@ function renderScript(harnesses, copy) {
1350
1248
  const byHarnessId = new Map(harnessData.map((item) => ['harness:' + item.id, item]));
1351
1249
  const selectedPanel = document.getElementById('selected-panel');
1352
1250
  const graphStatus = document.getElementById('graph-status');
1353
- const tooltip = document.getElementById('graph-tooltip');
1354
1251
  const filterStatus = document.getElementById('recommendation-filter-status');
1355
- const nodes = Array.from(document.querySelectorAll('.graph-node'));
1356
- const interactiveNodes = nodes.filter((item) => item.classList.contains('is-interactive'));
1357
- const edges = Array.from(document.querySelectorAll('.graph-edge'));
1358
1252
  const cards = Array.from(document.querySelectorAll('.harness-card'));
1359
1253
  const recLabelByFilter = Object.fromEntries(
1360
1254
  Array.from(document.querySelectorAll('[data-filter-rec]')).map((button) => [
@@ -1373,51 +1267,25 @@ function renderScript(harnesses, copy) {
1373
1267
  const text = String(value ?? '').trim();
1374
1268
  return text && text.toLowerCase() !== 'unknown' ? text : (copy.notSet || 'Not set');
1375
1269
  };
1376
- const nodeById = (id) => nodes.find((node) => node.dataset.nodeId === id);
1377
1270
  const setStatus = (value) => {
1378
1271
  if (graphStatus) graphStatus.textContent = value;
1379
1272
  };
1380
1273
  const setFilterStatus = (value) => {
1381
1274
  if (filterStatus) filterStatus.textContent = value;
1382
1275
  };
1383
- const graphCanvas = document.querySelector('.graph-canvas');
1384
- const orbitSystems = Array.from(document.querySelectorAll('.orbit-system'));
1385
1276
  const defaultSelectedPanel = selectedPanel ? selectedPanel.innerHTML : '';
1386
- function syncOrbits() {
1387
- const hasSelection = graphCanvas && graphCanvas.classList.contains('has-selection');
1388
- orbitSystems.forEach((system) => {
1389
- const parent = nodeById(system.dataset.parent);
1390
- const hidden = !parent || parent.classList.contains('is-hidden') || parent.classList.contains('is-filtered-out');
1391
- system.classList.toggle('is-hidden', hidden);
1392
- const related = parent && (parent.classList.contains('is-selected') || parent.classList.contains('is-related'));
1393
- system.classList.toggle('is-dimmed', Boolean(hasSelection && !related));
1394
- });
1395
- }
1396
- function clearSelection() {
1397
- nodes.forEach((item) => item.classList.remove('is-selected', 'is-related'));
1398
- edges.forEach((item) => item.classList.remove('is-related'));
1399
- cards.forEach((item) => item.classList.remove('is-selected'));
1400
- if (graphCanvas) graphCanvas.classList.remove('has-selection');
1401
- syncOrbits();
1402
- }
1403
- function selectNode(node) {
1404
- clearSelection();
1405
- node.classList.add('is-selected');
1406
- if (graphCanvas) graphCanvas.classList.add('has-selection');
1407
- const id = node.dataset.nodeId;
1408
- const item = byHarnessId.get(id);
1409
- edges.forEach((edge) => {
1410
- const related = edge.dataset.source === id || edge.dataset.target === id;
1411
- edge.classList.toggle('is-related', related);
1412
- if (related) {
1413
- const otherId = edge.dataset.source === id ? edge.dataset.target : edge.dataset.source;
1414
- const other = nodeById(otherId);
1415
- if (other) other.classList.add('is-related');
1277
+ window.__tinkGraphState = { mode: 'full', filter: null, pendingSelect: null };
1278
+ window.__tinkGraphBridge = {
1279
+ onSelect(info) {
1280
+ if (!info) {
1281
+ cards.forEach((card) => card.classList.remove('is-selected'));
1282
+ if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
1283
+ return;
1416
1284
  }
1417
- });
1418
- if (item) {
1419
- cards.forEach((card) => card.classList.toggle('is-selected', card.dataset.harnessId === item.id));
1420
- if (selectedPanel) {
1285
+ const item = byHarnessId.get(info.id);
1286
+ cards.forEach((card) => card.classList.toggle('is-selected', Boolean(item) && card.dataset.harnessId === item.id));
1287
+ if (!selectedPanel) return;
1288
+ if (item) {
1421
1289
  selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p>' +
1422
1290
  '<h2>' + esc(item.id) + '</h2>' +
1423
1291
  '<p>' + esc((item.reason || copy.clickNode)) + '</p>' +
@@ -1427,32 +1295,29 @@ function renderScript(harnesses, copy) {
1427
1295
  '<div><dt>' + esc(copy.uses) + '</dt><dd>' + esc(item.uses) + '</dd></div>' +
1428
1296
  '<div><dt>' + esc(copy.score) + '</dt><dd>' + esc(item.score) + '</dd></div>' +
1429
1297
  '</dl>';
1298
+ } else {
1299
+ selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p><h2>' + esc(info.label) + '</h2><p>' + esc(info.id) + '</p><dl><div><dt>' + esc(copy.type) + '</dt><dd>' + esc(displayValue(info.type)) + '</dd></div><div><dt>' + esc(copy.weight) + '</dt><dd>' + esc(info.weight) + '</dd></div></dl>';
1430
1300
  }
1431
- } else if (selectedPanel) {
1432
- selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p><h2>' + esc(node.dataset.nodeLabel) + '</h2><p>' + esc(id) + '</p><dl><div><dt>' + esc(copy.type) + '</dt><dd>' + esc(displayValue(node.dataset.nodeType)) + '</dd></div><div><dt>' + esc(copy.weight) + '</dt><dd>' + esc(node.dataset.nodeWeight) + '</dd></div></dl>';
1433
1301
  }
1434
- syncOrbits();
1435
- }
1302
+ };
1436
1303
  function selectHarness(id) {
1437
- const node = nodeById('harness:' + id);
1438
- if (node) {
1439
- selectNode(node);
1440
- node.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
1441
- }
1304
+ const nodeId = 'harness:' + id;
1305
+ if (window.__tinkGraph) window.__tinkGraph.select(nodeId);
1306
+ else window.__tinkGraphState.pendingSelect = nodeId;
1442
1307
  }
1443
- function applyMode(mode) {
1444
- document.querySelectorAll('[data-mode]').forEach((button) => {
1445
- const active = button.dataset.mode === mode;
1446
- button.classList.toggle('active', active);
1447
- button.setAttribute('aria-pressed', active ? 'true' : 'false');
1308
+ document.querySelectorAll('[data-mode]').forEach((button) => {
1309
+ button.addEventListener('click', () => {
1310
+ const mode = button.dataset.mode;
1311
+ document.querySelectorAll('[data-mode]').forEach((item) => {
1312
+ const active = item.dataset.mode === mode;
1313
+ item.classList.toggle('active', active);
1314
+ item.setAttribute('aria-pressed', active ? 'true' : 'false');
1315
+ });
1316
+ window.__tinkGraphState.mode = mode;
1317
+ if (window.__tinkGraph) window.__tinkGraph.setMode(mode);
1318
+ setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
1448
1319
  });
1449
- nodes.forEach((node) => node.classList.toggle('is-hidden', mode === 'core' && node.dataset.core !== 'true'));
1450
- const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-hidden')).map((node) => node.dataset.nodeId));
1451
- edges.forEach((edge) => edge.classList.toggle('is-hidden', mode === 'core' && (!visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target))));
1452
- document.querySelectorAll('.orbit-system').forEach((system) => system.classList.toggle('is-mode-hidden', mode === 'core'));
1453
- setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
1454
- syncOrbits();
1455
- }
1320
+ });
1456
1321
  function filterRecommendation(value, button) {
1457
1322
  const alreadyActive = button.classList.contains('active-filter');
1458
1323
  document.querySelectorAll('[data-filter-rec]').forEach((item) => {
@@ -1460,138 +1325,29 @@ function renderScript(harnesses, copy) {
1460
1325
  item.setAttribute('aria-pressed', 'false');
1461
1326
  });
1462
1327
  if (alreadyActive) {
1463
- nodes.forEach((node) => node.classList.remove('is-filtered-out'));
1464
- edges.forEach((edge) => edge.classList.remove('is-filtered-out'));
1465
1328
  cards.forEach((card) => card.classList.remove('is-filtered-out'));
1329
+ window.__tinkGraphState.filter = null;
1330
+ if (window.__tinkGraph) window.__tinkGraph.setFilter(null);
1466
1331
  setStatus(copy.showingAll);
1467
1332
  setFilterStatus(copy.showingAll);
1468
- syncOrbits();
1469
1333
  return;
1470
1334
  }
1471
1335
  button.classList.add('active-filter');
1472
1336
  button.setAttribute('aria-pressed', 'true');
1473
- nodes.forEach((node) => {
1474
- const hide = node.dataset.nodeType === 'harness' && node.dataset.recommendation !== value;
1475
- node.classList.toggle('is-filtered-out', hide);
1476
- });
1477
- const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-filtered-out')).map((node) => node.dataset.nodeId));
1478
- edges.forEach((edge) => edge.classList.toggle('is-filtered-out', !visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target)));
1479
1337
  cards.forEach((card) => card.classList.toggle('is-filtered-out', card.dataset.recommendation !== value));
1338
+ window.__tinkGraphState.filter = value;
1339
+ if (window.__tinkGraph) window.__tinkGraph.setFilter(value);
1480
1340
  const label = recLabelByFilter[value] || value;
1481
1341
  setStatus(copy.filteredTo + ': ' + label);
1482
1342
  setFilterStatus(copy.filteredTo + ': ' + label);
1483
- syncOrbits();
1484
1343
  }
1485
- interactiveNodes.forEach((node) => {
1486
- node.addEventListener('click', () => selectNode(node));
1487
- node.addEventListener('keydown', (event) => {
1488
- if (event.key === 'Enter' || event.key === ' ') {
1489
- event.preventDefault();
1490
- selectNode(node);
1491
- }
1492
- });
1493
- node.addEventListener('pointerenter', () => {
1494
- if (!tooltip) return;
1495
- const box = node.getBoundingClientRect();
1496
- const label = copy.tooltipPrefix + ': ' + node.dataset.nodeLabel;
1497
- const typeLabel = node.dataset.nodeType ? ' - ' + node.dataset.nodeType : '';
1498
- tooltip.textContent = label + typeLabel;
1499
- tooltip.style.left = (Math.min(Math.ceil(box.right + 12), window.innerWidth - 220)) + 'px';
1500
- tooltip.style.top = (Math.max(Math.ceil(box.top - 12), 12)) + 'px';
1501
- tooltip.classList.add('is-visible');
1502
- });
1503
- node.addEventListener('pointerleave', () => {
1504
- if (tooltip) {
1505
- tooltip.classList.remove('is-visible');
1506
- tooltip.textContent = '';
1507
- }
1344
+ document.querySelectorAll('[data-zoom]').forEach((button) => {
1345
+ button.addEventListener('click', () => {
1346
+ if (!window.__tinkGraph) return;
1347
+ if (button.dataset.zoom === 'reset') window.__tinkGraph.reset();
1348
+ else window.__tinkGraph.zoom(button.dataset.zoom === 'in' ? 0.78 : 1.28);
1508
1349
  });
1509
1350
  });
1510
- document.querySelectorAll('[data-mode]').forEach((button) => {
1511
- button.addEventListener('click', () => applyMode(button.dataset.mode));
1512
- });
1513
- const graphSvg = document.querySelector('.graph-canvas');
1514
- const graphViewport = document.getElementById('graph-viewport');
1515
- if (graphSvg && graphViewport) {
1516
- const view = { x: 0, y: 0, k: 1 };
1517
- graphViewport.style.transformOrigin = '0 0';
1518
- const applyView = () => {
1519
- graphViewport.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.k + ')';
1520
- };
1521
- const svgPoint = (event) => {
1522
- const pt = graphSvg.createSVGPoint();
1523
- pt.x = event.clientX;
1524
- pt.y = event.clientY;
1525
- return pt.matrixTransform(graphSvg.getScreenCTM().inverse());
1526
- };
1527
- const zoomAt = (factor, cx, cy) => {
1528
- const k = Math.min(5, Math.max(0.4, view.k * factor));
1529
- const real = k / view.k;
1530
- view.x = cx - (cx - view.x) * real;
1531
- view.y = cy - (cy - view.y) * real;
1532
- view.k = k;
1533
- applyView();
1534
- };
1535
- const resetView = () => {
1536
- graphViewport.classList.add('is-resetting');
1537
- view.x = 0; view.y = 0; view.k = 1;
1538
- applyView();
1539
- setTimeout(() => graphViewport.classList.remove('is-resetting'), 360);
1540
- };
1541
- graphSvg.addEventListener('wheel', (event) => {
1542
- event.preventDefault();
1543
- const point = svgPoint(event);
1544
- zoomAt(event.deltaY < 0 ? 1.15 : 1 / 1.15, point.x, point.y);
1545
- }, { passive: false });
1546
- let panState = null;
1547
- graphSvg.addEventListener('pointerdown', (event) => {
1548
- if (event.button !== 0) return;
1549
- panState = { x: event.clientX, y: event.clientY, moved: false };
1550
- graphSvg.setPointerCapture(event.pointerId);
1551
- });
1552
- graphSvg.addEventListener('pointermove', (event) => {
1553
- if (!panState) return;
1554
- const dx = event.clientX - panState.x;
1555
- const dy = event.clientY - panState.y;
1556
- if (!panState.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
1557
- panState.moved = true;
1558
- graphSvg.classList.add('is-panning');
1559
- const scale = 1090 / graphSvg.clientWidth;
1560
- view.x += dx * scale;
1561
- view.y += dy * scale;
1562
- panState.x = event.clientX;
1563
- panState.y = event.clientY;
1564
- applyView();
1565
- });
1566
- let suppressClick = false;
1567
- const endPan = () => {
1568
- suppressClick = Boolean(panState && panState.moved);
1569
- panState = null;
1570
- graphSvg.classList.remove('is-panning');
1571
- };
1572
- graphSvg.addEventListener('pointerup', endPan);
1573
- graphSvg.addEventListener('pointercancel', endPan);
1574
- graphSvg.addEventListener('click', (event) => {
1575
- if (suppressClick) {
1576
- suppressClick = false;
1577
- return;
1578
- }
1579
- if (event.target.classList.contains('graph-bg') || event.target.closest('.starfield')) {
1580
- clearSelection();
1581
- if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
1582
- }
1583
- });
1584
- graphSvg.addEventListener('dblclick', (event) => {
1585
- event.preventDefault();
1586
- resetView();
1587
- });
1588
- document.querySelectorAll('[data-zoom]').forEach((button) => {
1589
- button.addEventListener('click', () => {
1590
- if (button.dataset.zoom === 'reset') return resetView();
1591
- zoomAt(button.dataset.zoom === 'in' ? 1.3 : 1 / 1.3, 545, 340);
1592
- });
1593
- });
1594
- }
1595
1351
  const VALID_TABS = ['home', 'harnesses', 'memory', 'graph', 'activity'];
1596
1352
  const navLinks = Array.from(document.querySelectorAll('.nav a[data-tab]'));
1597
1353
  const pages = Array.from(document.querySelectorAll('.page'));
@@ -1643,6 +1399,448 @@ function renderScript(harnesses, copy) {
1643
1399
  `;
1644
1400
  }
1645
1401
 
1402
+ function renderGraph3DModule(copy) {
1403
+ const copyPayload = JSON.stringify({
1404
+ tooltipPrefix: copy.tooltipPrefix,
1405
+ type: copy.type
1406
+ }).replaceAll('<', '\\u003c');
1407
+ return `
1408
+ <script type="importmap">
1409
+ {
1410
+ "imports": {
1411
+ "three": "https://unpkg.com/three@0.162.0/build/three.module.js",
1412
+ "three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
1413
+ }
1414
+ }
1415
+ </script>
1416
+ <script type="module">
1417
+ (async () => {
1418
+ const container = document.getElementById('graph-3d');
1419
+ const dataEl = document.getElementById('graph-data');
1420
+ if (!container || !dataEl) return;
1421
+ let THREE, OrbitControls;
1422
+ try {
1423
+ THREE = await import('three');
1424
+ ({ OrbitControls } = await import('three/addons/controls/OrbitControls.js'));
1425
+ } catch (error) {
1426
+ const fallback = document.getElementById('graph-3d-fallback');
1427
+ if (fallback) fallback.hidden = false;
1428
+ return;
1429
+ }
1430
+ const data = JSON.parse(dataEl.textContent);
1431
+ const copy3d = ${copyPayload};
1432
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1433
+ const rootStyle = getComputedStyle(document.documentElement);
1434
+ const cssColor = (name, fallbackColor) => (rootStyle.getPropertyValue(name) || '').trim() || fallbackColor;
1435
+ const TYPE_HEX = {
1436
+ harness: cssColor('--accent', '#5B8DEF'),
1437
+ rule: '#9B8CFF',
1438
+ memory: '#4EC9B0',
1439
+ stage: '#D7A65A',
1440
+ unknown: '#7A8194'
1441
+ };
1442
+
1443
+ const scene = new THREE.Scene();
1444
+ scene.fog = new THREE.FogExp2(0x000000, 0.0019);
1445
+ const camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 1000);
1446
+ const INITIAL_CAM = new THREE.Vector3(0, 24, 52);
1447
+ const INITIAL_TARGET = new THREE.Vector3(0, 0, 0);
1448
+ camera.position.copy(INITIAL_CAM);
1449
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
1450
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
1451
+ renderer.setClearColor(0x000005, 1);
1452
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
1453
+ container.appendChild(renderer.domElement);
1454
+ const labelLayer = document.createElement('div');
1455
+ labelLayer.className = 'graph3d-labels';
1456
+ container.appendChild(labelLayer);
1457
+
1458
+ const controls = new OrbitControls(camera, renderer.domElement);
1459
+ controls.enableDamping = true;
1460
+ controls.dampingFactor = 0.08;
1461
+ controls.autoRotate = !reducedMotion;
1462
+ controls.autoRotateSpeed = 0.12;
1463
+ controls.minDistance = 14;
1464
+ controls.maxDistance = 160;
1465
+
1466
+ scene.add(new THREE.AmbientLight(0x8e9ab8, 0.85));
1467
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
1468
+ keyLight.position.set(30, 45, 25);
1469
+ scene.add(keyLight);
1470
+ const coreLight = new THREE.PointLight(0xffe8ff, 0.5, 260);
1471
+ coreLight.position.set(0, -14, 0);
1472
+ scene.add(coreLight);
1473
+
1474
+ function glowTexture(color) {
1475
+ const size = 128;
1476
+ const canvas = document.createElement('canvas');
1477
+ canvas.width = canvas.height = size;
1478
+ const context = canvas.getContext('2d');
1479
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
1480
+ gradient.addColorStop(0, color);
1481
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
1482
+ context.fillStyle = gradient;
1483
+ context.fillRect(0, 0, size, size);
1484
+ return new THREE.CanvasTexture(canvas);
1485
+ }
1486
+ function glowSprite(color, scale) {
1487
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
1488
+ map: glowTexture(color),
1489
+ transparent: true,
1490
+ depthWrite: false,
1491
+ blending: THREE.AdditiveBlending
1492
+ }));
1493
+ sprite.scale.set(scale, scale, 1);
1494
+ return sprite;
1495
+ }
1496
+
1497
+ // --- galaxy backdrop (same recipe as the three.js reference) ---
1498
+ const GALAXY = { count: 70000, arms: 4, radius: 95, spin: 1.9, randomness: 0.28, power: 3, y: -16, flatten: 0.22 };
1499
+ const galaxyPositions = new Float32Array(GALAXY.count * 3);
1500
+ const galaxyColors = new Float32Array(GALAXY.count * 3);
1501
+ const insideColor = new THREE.Color(0xff66ff);
1502
+ const outsideColor = new THREE.Color(0x66ffff);
1503
+ for (let i = 0; i < GALAXY.count; i += 1) {
1504
+ const i3 = i * 3;
1505
+ const radius = Math.pow(Math.random(), GALAXY.power) * GALAXY.radius;
1506
+ const branchAngle = ((i % GALAXY.arms) / GALAXY.arms) * Math.PI * 2;
1507
+ const spinAngle = (radius / GALAXY.radius) * GALAXY.spin * Math.PI;
1508
+ const randomX = (Math.random() - 0.5) * GALAXY.randomness * radius;
1509
+ const randomY = (Math.random() - 0.5) * GALAXY.randomness * radius * GALAXY.flatten;
1510
+ const randomZ = (Math.random() - 0.5) * GALAXY.randomness * radius;
1511
+ galaxyPositions[i3] = Math.cos(branchAngle + spinAngle) * radius + randomX;
1512
+ galaxyPositions[i3 + 1] = GALAXY.y + randomY;
1513
+ galaxyPositions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;
1514
+ const mixed = insideColor.clone().lerp(outsideColor, radius / GALAXY.radius);
1515
+ mixed.multiplyScalar(0.55 + 0.35 * Math.random());
1516
+ galaxyColors[i3] = mixed.r;
1517
+ galaxyColors[i3 + 1] = mixed.g;
1518
+ galaxyColors[i3 + 2] = mixed.b;
1519
+ }
1520
+ const galaxyGeometry = new THREE.BufferGeometry();
1521
+ galaxyGeometry.setAttribute('position', new THREE.BufferAttribute(galaxyPositions, 3));
1522
+ galaxyGeometry.setAttribute('color', new THREE.BufferAttribute(galaxyColors, 3));
1523
+ const galaxy = new THREE.Points(galaxyGeometry, new THREE.PointsMaterial({
1524
+ size: 0.16,
1525
+ vertexColors: true,
1526
+ depthWrite: false,
1527
+ transparent: true,
1528
+ opacity: 0.8,
1529
+ blending: THREE.AdditiveBlending
1530
+ }));
1531
+ scene.add(galaxy);
1532
+
1533
+ const starGeometry = new THREE.BufferGeometry();
1534
+ const starCount = 4000;
1535
+ const starPositions = new Float32Array(starCount * 3);
1536
+ for (let i = 0; i < starCount; i += 1) {
1537
+ starPositions[i * 3] = (Math.random() - 0.5) * 520;
1538
+ starPositions[i * 3 + 1] = (Math.random() - 0.5) * 520;
1539
+ starPositions[i * 3 + 2] = (Math.random() - 0.5) * 520;
1540
+ }
1541
+ starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
1542
+ const stars = new THREE.Points(starGeometry, new THREE.PointsMaterial({
1543
+ color: 0xffffff,
1544
+ size: 0.5,
1545
+ sizeAttenuation: true,
1546
+ transparent: true,
1547
+ opacity: 0.85
1548
+ }));
1549
+ scene.add(stars);
1550
+
1551
+ for (let i = 0; i < 10; i += 1) {
1552
+ const hue = Math.floor(Math.random() * 360);
1553
+ const nebula = glowSprite('hsla(' + hue + ', 80%, 55%, 0.4)', 50 + Math.random() * 70);
1554
+ nebula.position.set((Math.random() - 0.5) * 260, (Math.random() - 0.5) * 150 - 20, (Math.random() - 0.5) * 260);
1555
+ scene.add(nebula);
1556
+ }
1557
+ const coreGlow = glowSprite('rgba(255,240,255,0.85)', 26);
1558
+ coreGlow.position.set(0, GALAXY.y, 0);
1559
+ scene.add(coreGlow);
1560
+
1561
+ // --- harness map: spheres, edges, pulses, labels ---
1562
+ function surfaceTexture() {
1563
+ const canvas = document.createElement('canvas');
1564
+ canvas.width = 256;
1565
+ canvas.height = 128;
1566
+ const context = canvas.getContext('2d');
1567
+ context.fillStyle = '#B9BDC6';
1568
+ context.fillRect(0, 0, 256, 128);
1569
+ for (let i = 0; i < 70; i += 1) {
1570
+ const shade = 150 + Math.floor(Math.random() * 105);
1571
+ context.fillStyle = 'rgba(' + shade + ',' + shade + ',' + (shade + 8) + ',0.35)';
1572
+ context.beginPath();
1573
+ context.ellipse(Math.random() * 256, Math.random() * 128, 5 + Math.random() * 26, 3 + Math.random() * 10, Math.random() * Math.PI, 0, Math.PI * 2);
1574
+ context.fill();
1575
+ }
1576
+ for (let band = 0; band < 4; band += 1) {
1577
+ context.fillStyle = 'rgba(90,96,110,0.18)';
1578
+ context.fillRect(0, 16 + band * 30 + Math.random() * 8, 256, 5 + Math.random() * 8);
1579
+ }
1580
+ const texture = new THREE.CanvasTexture(canvas);
1581
+ texture.wrapS = THREE.RepeatWrapping;
1582
+ return texture;
1583
+ }
1584
+ const sphereTexture = surfaceTexture();
1585
+ const sphereGeometry = new THREE.SphereGeometry(1, 32, 24);
1586
+ const graphGroup = new THREE.Group();
1587
+ scene.add(graphGroup);
1588
+
1589
+ const nodeEntries = data.nodes.map((node) => {
1590
+ const colorHex = TYPE_HEX[node.type] || TYPE_HEX.unknown;
1591
+ const color = new THREE.Color(colorHex);
1592
+ const material = new THREE.MeshStandardMaterial({
1593
+ color,
1594
+ map: sphereTexture,
1595
+ roughness: 0.55,
1596
+ metalness: 0.1,
1597
+ emissive: color,
1598
+ emissiveIntensity: 0.25,
1599
+ transparent: true,
1600
+ opacity: 1
1601
+ });
1602
+ const mesh = new THREE.Mesh(sphereGeometry, material);
1603
+ mesh.position.set(node.x, node.y, node.z);
1604
+ mesh.scale.setScalar(node.r);
1605
+ mesh.userData = node;
1606
+ graphGroup.add(mesh);
1607
+ let ring = null;
1608
+ if (node.type === 'harness' && node.glow) {
1609
+ ring = new THREE.Mesh(
1610
+ new THREE.RingGeometry(node.r * 1.5, node.r * 2.15, 48),
1611
+ new THREE.MeshBasicMaterial({ color: 0x9aa6c0, side: THREE.DoubleSide, transparent: true, opacity: 0.3, depthWrite: false })
1612
+ );
1613
+ ring.position.copy(mesh.position);
1614
+ ring.rotation.x = Math.PI / 2.4;
1615
+ graphGroup.add(ring);
1616
+ }
1617
+ const label = document.createElement('span');
1618
+ label.className = 'graph3d-label' + (node.type === 'harness' ? '' : ' is-minor');
1619
+ label.textContent = node.label;
1620
+ labelLayer.appendChild(label);
1621
+ return { node, mesh, ring, label };
1622
+ });
1623
+ const entryById = new Map(nodeEntries.map((entry) => [entry.node.id, entry]));
1624
+
1625
+ const edgeEntries = data.edges.map((edge) => {
1626
+ const source = entryById.get(edge.source);
1627
+ const target = entryById.get(edge.target);
1628
+ if (!source || !target) return null;
1629
+ const geometry = new THREE.BufferGeometry().setFromPoints([source.mesh.position, target.mesh.position]);
1630
+ const material = new THREE.LineBasicMaterial({
1631
+ color: new THREE.Color(TYPE_HEX[source.node.type] || TYPE_HEX.unknown),
1632
+ transparent: true,
1633
+ opacity: 0.3,
1634
+ depthWrite: false,
1635
+ blending: THREE.AdditiveBlending
1636
+ });
1637
+ const line = new THREE.Line(geometry, material);
1638
+ graphGroup.add(line);
1639
+ const pulse = glowSprite('rgba(190,215,255,0.95)', 1.5);
1640
+ pulse.material.opacity = 0;
1641
+ graphGroup.add(pulse);
1642
+ return {
1643
+ edge,
1644
+ source,
1645
+ target,
1646
+ line,
1647
+ pulse,
1648
+ dur: 2.6 + Math.random() * 3.2,
1649
+ phase: Math.random()
1650
+ };
1651
+ }).filter(Boolean);
1652
+
1653
+ // --- state: selection, mode, filter ---
1654
+ const state = window.__tinkGraphState || { mode: 'full', filter: null, pendingSelect: null };
1655
+ let selectedId = null;
1656
+ function relatedIds(id) {
1657
+ const related = new Set([id]);
1658
+ edgeEntries.forEach((entry) => {
1659
+ if (entry.edge.source === id) related.add(entry.edge.target);
1660
+ if (entry.edge.target === id) related.add(entry.edge.source);
1661
+ });
1662
+ return related;
1663
+ }
1664
+ function applyState() {
1665
+ const related = selectedId ? relatedIds(selectedId) : null;
1666
+ nodeEntries.forEach((entry) => {
1667
+ const visible = state.mode !== 'core' || entry.node.core;
1668
+ entry.mesh.visible = visible;
1669
+ let dim = 1;
1670
+ if (state.filter && entry.node.type === 'harness' && entry.node.recommendation !== state.filter) dim = 0.12;
1671
+ if (related && !related.has(entry.node.id)) dim = Math.min(dim, 0.1);
1672
+ const isSelected = entry.node.id === selectedId;
1673
+ entry.mesh.material.opacity = dim;
1674
+ entry.mesh.material.emissiveIntensity = isSelected ? 0.85 : (related && related.has(entry.node.id) ? 0.45 : 0.25);
1675
+ entry.mesh.scale.setScalar(entry.node.r * (isSelected ? 1.3 : 1));
1676
+ if (entry.ring) {
1677
+ entry.ring.visible = visible;
1678
+ entry.ring.material.opacity = 0.3 * dim;
1679
+ }
1680
+ entry.label.style.opacity = visible ? String(Math.max(dim, isSelected ? 1 : 0)) : '0';
1681
+ entry.label.classList.toggle('is-selected', isSelected);
1682
+ });
1683
+ edgeEntries.forEach((entry) => {
1684
+ const visible = entry.source.mesh.visible && entry.target.mesh.visible;
1685
+ entry.line.visible = visible;
1686
+ entry.pulse.visible = visible && !reducedMotion;
1687
+ const isRelated = related && (entry.edge.source === selectedId || entry.edge.target === selectedId);
1688
+ const filtered = state.filter && (entry.source.mesh.material.opacity < 0.2 || entry.target.mesh.material.opacity < 0.2);
1689
+ entry.line.material.opacity = related ? (isRelated ? 0.85 : 0.04) : (filtered ? 0.05 : 0.3);
1690
+ entry.pulseFactor = related ? (isRelated ? 1 : 0.05) : (filtered ? 0.08 : 1);
1691
+ });
1692
+ }
1693
+ function emitSelection() {
1694
+ if (!window.__tinkGraphBridge) return;
1695
+ if (!selectedId) {
1696
+ window.__tinkGraphBridge.onSelect(null);
1697
+ return;
1698
+ }
1699
+ const entry = entryById.get(selectedId);
1700
+ window.__tinkGraphBridge.onSelect(entry ? {
1701
+ id: entry.node.id,
1702
+ label: entry.node.label,
1703
+ type: entry.node.type,
1704
+ weight: entry.node.weight
1705
+ } : null);
1706
+ }
1707
+ window.__tinkGraph = {
1708
+ select(id) {
1709
+ if (!entryById.has(id)) return;
1710
+ selectedId = id;
1711
+ applyState();
1712
+ emitSelection();
1713
+ },
1714
+ clear() {
1715
+ selectedId = null;
1716
+ applyState();
1717
+ emitSelection();
1718
+ },
1719
+ setMode(mode) {
1720
+ state.mode = mode;
1721
+ applyState();
1722
+ },
1723
+ setFilter(value) {
1724
+ state.filter = value;
1725
+ applyState();
1726
+ },
1727
+ zoom(factor) {
1728
+ camera.position.sub(controls.target).multiplyScalar(factor).add(controls.target);
1729
+ controls.update();
1730
+ },
1731
+ reset() {
1732
+ camera.position.copy(INITIAL_CAM);
1733
+ controls.target.copy(INITIAL_TARGET);
1734
+ controls.update();
1735
+ }
1736
+ };
1737
+ applyState();
1738
+ if (state.pendingSelect) {
1739
+ window.__tinkGraph.select(state.pendingSelect);
1740
+ state.pendingSelect = null;
1741
+ }
1742
+
1743
+ // --- picking: hover tooltip + click selection ---
1744
+ const raycaster = new THREE.Raycaster();
1745
+ const pointer = new THREE.Vector2();
1746
+ const tooltip = document.getElementById('graph-tooltip');
1747
+ let downAt = null;
1748
+ function pick(event) {
1749
+ const rect = renderer.domElement.getBoundingClientRect();
1750
+ pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
1751
+ pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1752
+ raycaster.setFromCamera(pointer, camera);
1753
+ const meshes = nodeEntries.filter((entry) => entry.mesh.visible && entry.mesh.material.opacity > 0.2).map((entry) => entry.mesh);
1754
+ const hits = raycaster.intersectObjects(meshes, false);
1755
+ return hits.length ? hits[0].object.userData : null;
1756
+ }
1757
+ renderer.domElement.addEventListener('pointerdown', (event) => {
1758
+ downAt = { x: event.clientX, y: event.clientY };
1759
+ });
1760
+ renderer.domElement.addEventListener('pointerup', (event) => {
1761
+ if (!downAt) return;
1762
+ const moved = Math.abs(event.clientX - downAt.x) + Math.abs(event.clientY - downAt.y);
1763
+ downAt = null;
1764
+ if (moved > 6) return;
1765
+ const hit = pick(event);
1766
+ if (hit) window.__tinkGraph.select(hit.id);
1767
+ else window.__tinkGraph.clear();
1768
+ });
1769
+ renderer.domElement.addEventListener('pointermove', (event) => {
1770
+ const hit = pick(event);
1771
+ renderer.domElement.style.cursor = hit ? 'pointer' : 'grab';
1772
+ if (!tooltip) return;
1773
+ if (hit) {
1774
+ tooltip.textContent = copy3d.tooltipPrefix + ': ' + hit.label + ' - ' + hit.type;
1775
+ tooltip.style.left = Math.min(event.clientX + 14, window.innerWidth - 240) + 'px';
1776
+ tooltip.style.top = Math.max(event.clientY - 12, 12) + 'px';
1777
+ tooltip.classList.add('is-visible');
1778
+ } else {
1779
+ tooltip.classList.remove('is-visible');
1780
+ }
1781
+ });
1782
+ renderer.domElement.addEventListener('pointerleave', () => {
1783
+ if (tooltip) tooltip.classList.remove('is-visible');
1784
+ });
1785
+ renderer.domElement.addEventListener('dblclick', () => window.__tinkGraph.reset());
1786
+
1787
+ // --- sizing ---
1788
+ function resize() {
1789
+ const width = container.clientWidth;
1790
+ const height = container.clientHeight;
1791
+ if (!width || !height) return;
1792
+ camera.aspect = width / height;
1793
+ camera.updateProjectionMatrix();
1794
+ renderer.setSize(width, height);
1795
+ }
1796
+ new ResizeObserver(resize).observe(container);
1797
+ resize();
1798
+
1799
+ // --- animate ---
1800
+ const clock = new THREE.Clock();
1801
+ const projection = new THREE.Vector3();
1802
+ function animate() {
1803
+ requestAnimationFrame(animate);
1804
+ const delta = clock.getDelta();
1805
+ const elapsed = clock.elapsedTime;
1806
+ if (!container.clientWidth) return;
1807
+ controls.update();
1808
+ if (!reducedMotion) {
1809
+ galaxy.rotation.y += delta * 0.012;
1810
+ stars.rotation.y += delta * 0.004;
1811
+ nodeEntries.forEach((entry) => {
1812
+ entry.mesh.rotation.y += delta * entry.node.spin;
1813
+ });
1814
+ edgeEntries.forEach((entry) => {
1815
+ const t = ((elapsed / entry.dur) + entry.phase) % 1;
1816
+ entry.pulse.position.lerpVectors(entry.source.mesh.position, entry.target.mesh.position, t);
1817
+ entry.pulse.material.opacity = Math.sin(Math.PI * t) * 0.9 * (entry.pulseFactor ?? 1);
1818
+ });
1819
+ }
1820
+ const width = container.clientWidth;
1821
+ const height = container.clientHeight;
1822
+ nodeEntries.forEach((entry) => {
1823
+ projection.copy(entry.mesh.position);
1824
+ projection.y += entry.mesh.scale.x + 0.6;
1825
+ projection.project(camera);
1826
+ const behind = projection.z > 1;
1827
+ if (behind || !entry.mesh.visible) {
1828
+ entry.label.style.display = 'none';
1829
+ return;
1830
+ }
1831
+ entry.label.style.display = '';
1832
+ const x = (projection.x * 0.5 + 0.5) * width;
1833
+ const y = (-projection.y * 0.5 + 0.5) * height;
1834
+ entry.label.style.transform = 'translate(-50%, -100%) translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
1835
+ });
1836
+ renderer.render(scene, camera);
1837
+ }
1838
+ animate();
1839
+ })();
1840
+ </script>
1841
+ `;
1842
+ }
1843
+
1646
1844
  function renderStyles() {
1647
1845
  return `<style>
1648
1846
  :root {
@@ -2265,6 +2463,39 @@ function renderStyles() {
2265
2463
  to { opacity: 0.6; }
2266
2464
  }
2267
2465
 
2466
+ .galaxy-layer { pointer-events: none; }
2467
+
2468
+ .nebula { mix-blend-mode: screen; }
2469
+
2470
+ .galaxy-spiral {
2471
+ transform-origin: 545px 340px;
2472
+ transform-box: view-box;
2473
+ animation: galaxy-rotate 420s linear infinite;
2474
+ mix-blend-mode: screen;
2475
+ }
2476
+
2477
+ @keyframes galaxy-rotate {
2478
+ to { transform: rotate(360deg); }
2479
+ }
2480
+
2481
+ .galaxy-core {
2482
+ mix-blend-mode: screen;
2483
+ animation: core-breathe 9s ease-in-out infinite alternate;
2484
+ }
2485
+
2486
+ @keyframes core-breathe {
2487
+ from { opacity: 0.5; }
2488
+ to { opacity: 0.9; }
2489
+ }
2490
+
2491
+ .pulse-wrap { transition: opacity 260ms ease; }
2492
+ .pulse-wrap.is-hidden { opacity: 0; }
2493
+ .pulse-wrap.is-dimmed { opacity: 0.08; }
2494
+
2495
+ .edge-pulse {
2496
+ filter: drop-shadow(0 0 4px rgba(157, 196, 255, 0.9));
2497
+ }
2498
+
2268
2499
  .orbit-ring {
2269
2500
  fill: none;
2270
2501
  stroke: var(--text-secondary);
@@ -2312,6 +2543,64 @@ function renderStyles() {
2312
2543
 
2313
2544
  .graph-canvas.has-selection .labels text { opacity: 0.18; transition: opacity 220ms ease; }
2314
2545
 
2546
+ .graph-3d {
2547
+ position: relative;
2548
+ height: 620px;
2549
+ border-radius: var(--radius-lg);
2550
+ overflow: hidden;
2551
+ background: #000005;
2552
+ cursor: grab;
2553
+ user-select: none;
2554
+ -webkit-user-select: none;
2555
+ touch-action: none;
2556
+ }
2557
+
2558
+ .graph-3d:active { cursor: grabbing; }
2559
+
2560
+ .graph-3d canvas { display: block; }
2561
+
2562
+ .graph3d-labels {
2563
+ position: absolute;
2564
+ inset: 0;
2565
+ pointer-events: none;
2566
+ overflow: hidden;
2567
+ }
2568
+
2569
+ .graph3d-label {
2570
+ position: absolute;
2571
+ left: 0;
2572
+ top: 0;
2573
+ font-family: var(--font-mono);
2574
+ font-size: 11px;
2575
+ color: rgba(222, 230, 248, 0.88);
2576
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95);
2577
+ white-space: nowrap;
2578
+ transition: opacity 220ms ease;
2579
+ will-change: transform;
2580
+ }
2581
+
2582
+ .graph3d-label.is-minor {
2583
+ font-size: 10px;
2584
+ color: rgba(168, 178, 200, 0.6);
2585
+ }
2586
+
2587
+ .graph3d-label.is-selected {
2588
+ color: #FFFFFF;
2589
+ font-weight: 600;
2590
+ }
2591
+
2592
+ .graph-3d-fallback {
2593
+ position: absolute;
2594
+ inset: 0;
2595
+ display: grid;
2596
+ place-items: center;
2597
+ margin: 0;
2598
+ padding: var(--space-4);
2599
+ color: var(--text-secondary);
2600
+ font-size: 13px;
2601
+ text-align: center;
2602
+ }
2603
+
2315
2604
  .map-controls-row {
2316
2605
  display: flex;
2317
2606
  gap: var(--space-2);
@@ -2421,8 +2710,11 @@ function renderStyles() {
2421
2710
  .graph-canvas text,
2422
2711
  .orbit-spin,
2423
2712
  .star-twinkle,
2713
+ .galaxy-spiral,
2714
+ .galaxy-core,
2424
2715
  .page.is-active { animation: none; }
2425
2716
  .graph-node { transition: none; }
2717
+ .pulses { display: none; }
2426
2718
  }
2427
2719
 
2428
2720
  .map-caption {
@@ -3229,6 +3521,7 @@ function renderReport(summary) {
3229
3521
  </div>
3230
3522
  ${renderContractMetadata(copy)}
3231
3523
  ${renderScript(harnesses, copy)}
3524
+ ${renderGraph3DModule(copy)}
3232
3525
  </body>
3233
3526
  </html>
3234
3527
  `;