tink-harness 1.9.8 → 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.8",
4
+ "version": "1.9.9",
5
5
  "author": {
6
6
  "name": "dotori"
7
7
  }
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ 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
+
9
21
  ## [1.9.8] - 2026-06-11
10
22
 
11
23
  ### Added
package/VERSIONING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Versioning
2
2
 
3
- Current version: `1.9.8`
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.8",
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,70 +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
- });
676
- const GALAXY = { count: 760, arms: 4, radius: 360, spin: 2.3, power: 2.4, cx: 545, cy: 340, flatten: 0.6 };
677
- const lerpChannel = (from, to, t) => Math.round(from + (to - from) * t);
678
- const galaxyDots = Array.from({ length: GALAXY.count }, (_, index) => {
679
- const s1 = (hashString(`gal:${index}:a`) % 10000) / 10000;
680
- const s2 = (hashString(`gal:${index}:b`) % 10000) / 10000;
681
- const s3 = (hashString(`gal:${index}:c`) % 10000) / 10000;
682
- const s4 = (hashString(`gal:${index}:d`) % 10000) / 10000;
683
- const radius = Math.pow(s1, GALAXY.power) * GALAXY.radius;
684
- const t = radius / GALAXY.radius;
685
- const branchAngle = ((index % GALAXY.arms) / GALAXY.arms) * Math.PI * 2;
686
- const spinAngle = t * GALAXY.spin;
687
- const randomX = (s2 - 0.5) * 0.42 * radius;
688
- const randomY = (s3 - 0.5) * 0.42 * radius;
689
- const totalAngle = branchAngle + spinAngle;
690
- // inside #FF66FF -> outside #66FFFF, like the reference galaxy
691
- const color = `rgb(${lerpChannel(255, 102, t)}, ${lerpChannel(102, 255, t)}, 255)`;
692
- return {
693
- x: (GALAXY.cx + (Math.cos(totalAngle) * radius + randomX)).toFixed(1),
694
- y: (GALAXY.cy + (Math.sin(totalAngle) * radius + randomY) * GALAXY.flatten).toFixed(1),
695
- r: (0.5 + s4 * 1.1).toFixed(1),
696
- color,
697
- opacity: (0.12 + s4 * 0.34).toFixed(2)
698
- };
699
- });
700
- const nebulae = Array.from({ length: 7 }, (_, index) => {
701
- const seed = hashString(`nebula:${index}`);
702
- return {
703
- id: index,
704
- hue: seed % 360,
705
- x: 80 + (seed % 930),
706
- y: 60 + ((seed >> 5) % 560),
707
- r: 70 + ((seed >> 3) % 150),
708
- opacity: (0.05 + ((seed >> 7) % 8) / 100).toFixed(2)
709
- };
710
- });
711
- const pulses = edges.slice(0, 120).map((edge, index) => {
712
- const seed = hashString(`pulse:${edge.source}:${edge.target}:${index}`);
713
- return {
714
- path: `M ${edge.sourceNode.x.toFixed(1)},${edge.sourceNode.y.toFixed(1)} L ${edge.targetNode.x.toFixed(1)},${edge.targetNode.y.toFixed(1)}`,
715
- dur: (3.2 + (seed % 42) / 10).toFixed(1),
716
- begin: -((seed >> 4) % 7000),
717
- index
718
- };
719
- });
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');
720
671
  const mapTitle = copy.knowledgeGraph || copy.harnessMap || 'Harness map';
721
672
  const mapEyebrow = copy.harnessMap && copy.harnessMap !== mapTitle ? copy.harnessMap : '';
722
673
  return `
@@ -739,142 +690,10 @@ function renderGraphCanvas(summary, copy) {
739
690
  </div>
740
691
  </div>
741
692
  </div>
742
- <svg class="graph-canvas" viewBox="0 0 1090 680" role="img" aria-label="Harness health graph">
743
- <defs>
744
- <radialGradient id="graph-bg-grad" cx="50%" cy="42%" r="80%">
745
- <stop offset="0%" style="stop-color: #0B0E1A"/>
746
- <stop offset="60%" style="stop-color: #05060F"/>
747
- <stop offset="100%" style="stop-color: #000005"/>
748
- </radialGradient>
749
- <radialGradient id="galaxy-core-grad" cx="50%" cy="50%" r="50%">
750
- <stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.55"/>
751
- <stop offset="35%" style="stop-color: #C9B8FF; stop-opacity: 0.18"/>
752
- <stop offset="100%" style="stop-color: #C9B8FF; stop-opacity: 0"/>
753
- </radialGradient>
754
- ${nebulae.map((nebula) => `
755
- <radialGradient id="nebula-grad-${nebula.id}" cx="50%" cy="50%" r="50%">
756
- <stop offset="0%" style="stop-color: hsl(${nebula.hue}, 80%, 60%); stop-opacity: 0.6"/>
757
- <stop offset="100%" style="stop-color: hsl(${nebula.hue}, 80%, 60%); stop-opacity: 0"/>
758
- </radialGradient>
759
- `).join('')}
760
- <radialGradient id="pulse-grad" cx="50%" cy="50%" r="50%">
761
- <stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 1"/>
762
- <stop offset="45%" style="stop-color: #9DC4FF; stop-opacity: 0.85"/>
763
- <stop offset="100%" style="stop-color: #5B8DEF; stop-opacity: 0"/>
764
- </radialGradient>
765
- ${Object.entries(TYPE_COLORS).map(([type, color]) => `
766
- <radialGradient id="node-grad-${escapeAttr(type)}" cx="32%" cy="28%" r="78%">
767
- <stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.42"/>
768
- <stop offset="38%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.98"/>
769
- <stop offset="100%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.78"/>
770
- </radialGradient>
771
- `).join('')}
772
- </defs>
773
- <rect class="graph-bg" width="1090" height="680" fill="url(#graph-bg-grad)"/>
774
- <g class="starfield" aria-hidden="true">
775
- ${stars.map((star) => `
776
- <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"` : ''}/>
777
- `).join('')}
778
- </g>
779
- <g class="galaxy-layer" aria-hidden="true">
780
- ${nebulae.map((nebula) => `
781
- <circle class="nebula" cx="${nebula.x}" cy="${nebula.y}" r="${nebula.r}" fill="url(#nebula-grad-${nebula.id})" opacity="${nebula.opacity}"/>
782
- `).join('')}
783
- <g class="galaxy-spiral">
784
- ${galaxyDots.map((dot) => `<circle cx="${dot.x}" cy="${dot.y}" r="${dot.r}" fill="${dot.color}" opacity="${dot.opacity}"/>`).join('')}
785
- </g>
786
- <circle class="galaxy-core" cx="${GALAXY.cx}" cy="${GALAXY.cy}" r="120" fill="url(#galaxy-core-grad)"/>
787
- </g>
788
- <g id="graph-viewport">
789
- <g class="edges">
790
- ${edges.map((edge, index) => `
791
- <line
792
- class="graph-edge"
793
- style="--edge-delay: ${Math.min(index * 5, 850)}ms"
794
- data-source="${escapeAttr(edge.source)}"
795
- data-target="${escapeAttr(edge.target)}"
796
- x1="${edge.sourceNode.x.toFixed(1)}"
797
- y1="${edge.sourceNode.y.toFixed(1)}"
798
- x2="${edge.targetNode.x.toFixed(1)}"
799
- y2="${edge.targetNode.y.toFixed(1)}"
800
- stroke="${escapeAttr(edge.sourceNode.color)}"
801
- stroke-opacity="0.12"
802
- stroke-width="${clamp(Number(edge.count || 1), 1, 5)}"
803
- />
804
- `).join('')}
805
- </g>
806
- <g class="pulses" aria-hidden="true">
807
- ${pulses.map((pulse) => `
808
- <g class="pulse-wrap" data-pulse-index="${pulse.index}">
809
- <circle class="edge-pulse" r="2.6" fill="url(#pulse-grad)" opacity="0">
810
- <animateMotion dur="${pulse.dur}s" begin="${pulse.begin}ms" repeatCount="indefinite" path="${escapeAttr(pulse.path)}"/>
811
- <animate attributeName="opacity" values="0;0.95;0.95;0" keyTimes="0;0.12;0.85;1" dur="${pulse.dur}s" begin="${pulse.begin}ms" repeatCount="indefinite"/>
812
- </circle>
813
- </g>
814
- `).join('')}
815
- </g>
816
- <g class="orbits" aria-hidden="true">
817
- ${orbitSystems.map((system) => `
818
- <g class="orbit-system" data-parent="${escapeAttr(system.parentId)}" transform="translate(${system.x.toFixed(1)} ${system.y.toFixed(1)})">
819
- ${system.rings.map((ring) => {
820
- const dots = Array.from({ length: ring.count }, (_, dotIndex) => {
821
- const angle = ring.phase + (dotIndex * Math.PI * 2) / ring.count;
822
- 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)})"/>`;
823
- }).join('');
824
- return `
825
- <circle class="orbit-ring" cx="0" cy="0" r="${ring.distance.toFixed(1)}"/>
826
- <g class="orbit-spin${ring.reverse ? ' is-reverse' : ''}" style="--orbit-dur: ${ring.duration}s">${dots}</g>
827
- `;
828
- }).join('')}
829
- </g>
830
- `).join('')}
831
- </g>
832
- <g class="nodes">
833
- ${nodes.map((node, index) => {
834
- const seed = hashString(node.id);
835
- const floatDuration = (5 + (seed % 50) / 10).toFixed(1);
836
- const floatDelay = -(seed % 4000);
837
- const floatX = ((seed % 7) - 3).toFixed(1);
838
- const floatY = (((seed >> 3) % 7) - 3).toFixed(1);
839
- return `
840
- <g class="node-float" style="--float-dur: ${floatDuration}s; --float-delay: ${floatDelay}ms; --float-x: ${floatX}px; --float-y: ${floatY}px">
841
- ${node.type === 'harness' && node.radius >= 15 ? `
842
- <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)})"/>
843
- ` : ''}
844
- <circle
845
- class="graph-node ${node.type === 'harness' ? 'is-interactive' : ''}"
846
- style="--enter-delay: ${Math.min(index * 9, 1100)}ms"
847
- tabindex="${node.type === 'harness' ? '0' : '-1'}"
848
- role="${node.type === 'harness' ? 'button' : 'presentation'}"
849
- aria-label="${escapeAttr(`${copy.tooltipPrefix}: ${node.label}`)}"
850
- data-node-id="${escapeAttr(node.id)}"
851
- data-node-type="${escapeAttr(node.type)}"
852
- data-node-label="${escapeAttr(node.label)}"
853
- data-node-weight="${escapeAttr(node.weight || 0)}"
854
- data-core="${node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1 ? 'true' : 'false'}"
855
- data-recommendation="${escapeAttr(node.recommendation || '')}"
856
- cx="${node.x.toFixed(1)}"
857
- cy="${node.y.toFixed(1)}"
858
- r="${node.radius.toFixed(1)}"
859
- fill="url(#node-grad-${escapeAttr(TYPE_COLORS[node.type] ? node.type : 'unknown')})"
860
- fill-opacity="${node.type === 'harness' ? '1' : '0.85'}"
861
- stroke="${escapeAttr('var(--text-secondary)')}"
862
- stroke-opacity="${node.glow ? '0.9' : '0.18'}"
863
- stroke-width="${node.glow ? '1.8' : '0.8'}"
864
- >
865
- <title>${escapeHtml(node.id)}</title>
866
- </circle>
867
- </g>
868
- `;
869
- }).join('')}
870
- </g>
871
- <g class="labels">
872
- ${strongest.map((node) => `
873
- <text x="${(node.x + node.radius + 7).toFixed(1)}" y="${(node.y + 4).toFixed(1)}">${escapeHtml(node.label)}</text>
874
- `).join('')}
875
- </g>
876
- </g>
877
- </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>
878
697
  <div class="graph-tooltip" id="graph-tooltip" role="status" aria-live="polite"></div>
879
698
  <div class="map-caption">
880
699
  <span id="graph-status">${escapeHtml(copy.showingAll)}</span>
@@ -882,8 +701,8 @@ function renderGraphCanvas(summary, copy) {
882
701
  <span>${escapeHtml(copy.linesRelations)}</span>
883
702
  </div>
884
703
  <div class="map-legend" aria-label="${escapeAttr(copy.nodeTypes || 'Node types')}">
885
- ${['harness', 'rule', 'memory', 'stage', 'signal', 'evidence', 'score'].map((type) => `
886
- <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>
887
706
  `).join('')}
888
707
  </div>
889
708
  </section>
@@ -1429,11 +1248,7 @@ function renderScript(harnesses, copy) {
1429
1248
  const byHarnessId = new Map(harnessData.map((item) => ['harness:' + item.id, item]));
1430
1249
  const selectedPanel = document.getElementById('selected-panel');
1431
1250
  const graphStatus = document.getElementById('graph-status');
1432
- const tooltip = document.getElementById('graph-tooltip');
1433
1251
  const filterStatus = document.getElementById('recommendation-filter-status');
1434
- const nodes = Array.from(document.querySelectorAll('.graph-node'));
1435
- const interactiveNodes = nodes.filter((item) => item.classList.contains('is-interactive'));
1436
- const edges = Array.from(document.querySelectorAll('.graph-edge'));
1437
1252
  const cards = Array.from(document.querySelectorAll('.harness-card'));
1438
1253
  const recLabelByFilter = Object.fromEntries(
1439
1254
  Array.from(document.querySelectorAll('[data-filter-rec]')).map((button) => [
@@ -1452,63 +1267,25 @@ function renderScript(harnesses, copy) {
1452
1267
  const text = String(value ?? '').trim();
1453
1268
  return text && text.toLowerCase() !== 'unknown' ? text : (copy.notSet || 'Not set');
1454
1269
  };
1455
- const nodeById = (id) => nodes.find((node) => node.dataset.nodeId === id);
1456
1270
  const setStatus = (value) => {
1457
1271
  if (graphStatus) graphStatus.textContent = value;
1458
1272
  };
1459
1273
  const setFilterStatus = (value) => {
1460
1274
  if (filterStatus) filterStatus.textContent = value;
1461
1275
  };
1462
- const graphCanvas = document.querySelector('.graph-canvas');
1463
- const orbitSystems = Array.from(document.querySelectorAll('.orbit-system'));
1464
- const pulseWraps = Array.from(document.querySelectorAll('.pulse-wrap'));
1465
1276
  const defaultSelectedPanel = selectedPanel ? selectedPanel.innerHTML : '';
1466
- function syncPulses() {
1467
- const hasSelection = graphCanvas && graphCanvas.classList.contains('has-selection');
1468
- pulseWraps.forEach((wrap) => {
1469
- const edge = edges[Number(wrap.dataset.pulseIndex)];
1470
- if (!edge) return;
1471
- const hidden = edge.classList.contains('is-hidden') || edge.classList.contains('is-filtered-out');
1472
- wrap.classList.toggle('is-hidden', hidden);
1473
- wrap.classList.toggle('is-dimmed', Boolean(hasSelection && !edge.classList.contains('is-related')));
1474
- });
1475
- }
1476
- function syncOrbits() {
1477
- const hasSelection = graphCanvas && graphCanvas.classList.contains('has-selection');
1478
- orbitSystems.forEach((system) => {
1479
- const parent = nodeById(system.dataset.parent);
1480
- const hidden = !parent || parent.classList.contains('is-hidden') || parent.classList.contains('is-filtered-out');
1481
- system.classList.toggle('is-hidden', hidden);
1482
- const related = parent && (parent.classList.contains('is-selected') || parent.classList.contains('is-related'));
1483
- system.classList.toggle('is-dimmed', Boolean(hasSelection && !related));
1484
- });
1485
- syncPulses();
1486
- }
1487
- function clearSelection() {
1488
- nodes.forEach((item) => item.classList.remove('is-selected', 'is-related'));
1489
- edges.forEach((item) => item.classList.remove('is-related'));
1490
- cards.forEach((item) => item.classList.remove('is-selected'));
1491
- if (graphCanvas) graphCanvas.classList.remove('has-selection');
1492
- syncOrbits();
1493
- }
1494
- function selectNode(node) {
1495
- clearSelection();
1496
- node.classList.add('is-selected');
1497
- if (graphCanvas) graphCanvas.classList.add('has-selection');
1498
- const id = node.dataset.nodeId;
1499
- const item = byHarnessId.get(id);
1500
- edges.forEach((edge) => {
1501
- const related = edge.dataset.source === id || edge.dataset.target === id;
1502
- edge.classList.toggle('is-related', related);
1503
- if (related) {
1504
- const otherId = edge.dataset.source === id ? edge.dataset.target : edge.dataset.source;
1505
- const other = nodeById(otherId);
1506
- 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;
1507
1284
  }
1508
- });
1509
- if (item) {
1510
- cards.forEach((card) => card.classList.toggle('is-selected', card.dataset.harnessId === item.id));
1511
- 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) {
1512
1289
  selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p>' +
1513
1290
  '<h2>' + esc(item.id) + '</h2>' +
1514
1291
  '<p>' + esc((item.reason || copy.clickNode)) + '</p>' +
@@ -1518,32 +1295,29 @@ function renderScript(harnesses, copy) {
1518
1295
  '<div><dt>' + esc(copy.uses) + '</dt><dd>' + esc(item.uses) + '</dd></div>' +
1519
1296
  '<div><dt>' + esc(copy.score) + '</dt><dd>' + esc(item.score) + '</dd></div>' +
1520
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>';
1521
1300
  }
1522
- } else if (selectedPanel) {
1523
- 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>';
1524
1301
  }
1525
- syncOrbits();
1526
- }
1302
+ };
1527
1303
  function selectHarness(id) {
1528
- const node = nodeById('harness:' + id);
1529
- if (node) {
1530
- selectNode(node);
1531
- node.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
1532
- }
1304
+ const nodeId = 'harness:' + id;
1305
+ if (window.__tinkGraph) window.__tinkGraph.select(nodeId);
1306
+ else window.__tinkGraphState.pendingSelect = nodeId;
1533
1307
  }
1534
- function applyMode(mode) {
1535
- document.querySelectorAll('[data-mode]').forEach((button) => {
1536
- const active = button.dataset.mode === mode;
1537
- button.classList.toggle('active', active);
1538
- 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);
1539
1319
  });
1540
- nodes.forEach((node) => node.classList.toggle('is-hidden', mode === 'core' && node.dataset.core !== 'true'));
1541
- const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-hidden')).map((node) => node.dataset.nodeId));
1542
- edges.forEach((edge) => edge.classList.toggle('is-hidden', mode === 'core' && (!visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target))));
1543
- document.querySelectorAll('.orbit-system').forEach((system) => system.classList.toggle('is-mode-hidden', mode === 'core'));
1544
- setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
1545
- syncOrbits();
1546
- }
1320
+ });
1547
1321
  function filterRecommendation(value, button) {
1548
1322
  const alreadyActive = button.classList.contains('active-filter');
1549
1323
  document.querySelectorAll('[data-filter-rec]').forEach((item) => {
@@ -1551,138 +1325,29 @@ function renderScript(harnesses, copy) {
1551
1325
  item.setAttribute('aria-pressed', 'false');
1552
1326
  });
1553
1327
  if (alreadyActive) {
1554
- nodes.forEach((node) => node.classList.remove('is-filtered-out'));
1555
- edges.forEach((edge) => edge.classList.remove('is-filtered-out'));
1556
1328
  cards.forEach((card) => card.classList.remove('is-filtered-out'));
1329
+ window.__tinkGraphState.filter = null;
1330
+ if (window.__tinkGraph) window.__tinkGraph.setFilter(null);
1557
1331
  setStatus(copy.showingAll);
1558
1332
  setFilterStatus(copy.showingAll);
1559
- syncOrbits();
1560
1333
  return;
1561
1334
  }
1562
1335
  button.classList.add('active-filter');
1563
1336
  button.setAttribute('aria-pressed', 'true');
1564
- nodes.forEach((node) => {
1565
- const hide = node.dataset.nodeType === 'harness' && node.dataset.recommendation !== value;
1566
- node.classList.toggle('is-filtered-out', hide);
1567
- });
1568
- const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-filtered-out')).map((node) => node.dataset.nodeId));
1569
- edges.forEach((edge) => edge.classList.toggle('is-filtered-out', !visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target)));
1570
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);
1571
1340
  const label = recLabelByFilter[value] || value;
1572
1341
  setStatus(copy.filteredTo + ': ' + label);
1573
1342
  setFilterStatus(copy.filteredTo + ': ' + label);
1574
- syncOrbits();
1575
1343
  }
1576
- interactiveNodes.forEach((node) => {
1577
- node.addEventListener('click', () => selectNode(node));
1578
- node.addEventListener('keydown', (event) => {
1579
- if (event.key === 'Enter' || event.key === ' ') {
1580
- event.preventDefault();
1581
- selectNode(node);
1582
- }
1583
- });
1584
- node.addEventListener('pointerenter', () => {
1585
- if (!tooltip) return;
1586
- const box = node.getBoundingClientRect();
1587
- const label = copy.tooltipPrefix + ': ' + node.dataset.nodeLabel;
1588
- const typeLabel = node.dataset.nodeType ? ' - ' + node.dataset.nodeType : '';
1589
- tooltip.textContent = label + typeLabel;
1590
- tooltip.style.left = (Math.min(Math.ceil(box.right + 12), window.innerWidth - 220)) + 'px';
1591
- tooltip.style.top = (Math.max(Math.ceil(box.top - 12), 12)) + 'px';
1592
- tooltip.classList.add('is-visible');
1593
- });
1594
- node.addEventListener('pointerleave', () => {
1595
- if (tooltip) {
1596
- tooltip.classList.remove('is-visible');
1597
- tooltip.textContent = '';
1598
- }
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);
1599
1349
  });
1600
1350
  });
1601
- document.querySelectorAll('[data-mode]').forEach((button) => {
1602
- button.addEventListener('click', () => applyMode(button.dataset.mode));
1603
- });
1604
- const graphSvg = document.querySelector('.graph-canvas');
1605
- const graphViewport = document.getElementById('graph-viewport');
1606
- if (graphSvg && graphViewport) {
1607
- const view = { x: 0, y: 0, k: 1 };
1608
- graphViewport.style.transformOrigin = '0 0';
1609
- const applyView = () => {
1610
- graphViewport.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.k + ')';
1611
- };
1612
- const svgPoint = (event) => {
1613
- const pt = graphSvg.createSVGPoint();
1614
- pt.x = event.clientX;
1615
- pt.y = event.clientY;
1616
- return pt.matrixTransform(graphSvg.getScreenCTM().inverse());
1617
- };
1618
- const zoomAt = (factor, cx, cy) => {
1619
- const k = Math.min(5, Math.max(0.4, view.k * factor));
1620
- const real = k / view.k;
1621
- view.x = cx - (cx - view.x) * real;
1622
- view.y = cy - (cy - view.y) * real;
1623
- view.k = k;
1624
- applyView();
1625
- };
1626
- const resetView = () => {
1627
- graphViewport.classList.add('is-resetting');
1628
- view.x = 0; view.y = 0; view.k = 1;
1629
- applyView();
1630
- setTimeout(() => graphViewport.classList.remove('is-resetting'), 360);
1631
- };
1632
- graphSvg.addEventListener('wheel', (event) => {
1633
- event.preventDefault();
1634
- const point = svgPoint(event);
1635
- zoomAt(event.deltaY < 0 ? 1.15 : 1 / 1.15, point.x, point.y);
1636
- }, { passive: false });
1637
- let panState = null;
1638
- graphSvg.addEventListener('pointerdown', (event) => {
1639
- if (event.button !== 0) return;
1640
- panState = { x: event.clientX, y: event.clientY, moved: false };
1641
- graphSvg.setPointerCapture(event.pointerId);
1642
- });
1643
- graphSvg.addEventListener('pointermove', (event) => {
1644
- if (!panState) return;
1645
- const dx = event.clientX - panState.x;
1646
- const dy = event.clientY - panState.y;
1647
- if (!panState.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
1648
- panState.moved = true;
1649
- graphSvg.classList.add('is-panning');
1650
- const scale = 1090 / graphSvg.clientWidth;
1651
- view.x += dx * scale;
1652
- view.y += dy * scale;
1653
- panState.x = event.clientX;
1654
- panState.y = event.clientY;
1655
- applyView();
1656
- });
1657
- let suppressClick = false;
1658
- const endPan = () => {
1659
- suppressClick = Boolean(panState && panState.moved);
1660
- panState = null;
1661
- graphSvg.classList.remove('is-panning');
1662
- };
1663
- graphSvg.addEventListener('pointerup', endPan);
1664
- graphSvg.addEventListener('pointercancel', endPan);
1665
- graphSvg.addEventListener('click', (event) => {
1666
- if (suppressClick) {
1667
- suppressClick = false;
1668
- return;
1669
- }
1670
- if (event.target.classList.contains('graph-bg') || event.target.closest('.starfield') || event.target.closest('.galaxy-layer')) {
1671
- clearSelection();
1672
- if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
1673
- }
1674
- });
1675
- graphSvg.addEventListener('dblclick', (event) => {
1676
- event.preventDefault();
1677
- resetView();
1678
- });
1679
- document.querySelectorAll('[data-zoom]').forEach((button) => {
1680
- button.addEventListener('click', () => {
1681
- if (button.dataset.zoom === 'reset') return resetView();
1682
- zoomAt(button.dataset.zoom === 'in' ? 1.3 : 1 / 1.3, 545, 340);
1683
- });
1684
- });
1685
- }
1686
1351
  const VALID_TABS = ['home', 'harnesses', 'memory', 'graph', 'activity'];
1687
1352
  const navLinks = Array.from(document.querySelectorAll('.nav a[data-tab]'));
1688
1353
  const pages = Array.from(document.querySelectorAll('.page'));
@@ -1734,6 +1399,448 @@ function renderScript(harnesses, copy) {
1734
1399
  `;
1735
1400
  }
1736
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
+
1737
1844
  function renderStyles() {
1738
1845
  return `<style>
1739
1846
  :root {
@@ -2436,6 +2543,64 @@ function renderStyles() {
2436
2543
 
2437
2544
  .graph-canvas.has-selection .labels text { opacity: 0.18; transition: opacity 220ms ease; }
2438
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
+
2439
2604
  .map-controls-row {
2440
2605
  display: flex;
2441
2606
  gap: var(--space-2);
@@ -3356,6 +3521,7 @@ function renderReport(summary) {
3356
3521
  </div>
3357
3522
  ${renderContractMetadata(copy)}
3358
3523
  ${renderScript(harnesses, copy)}
3524
+ ${renderGraph3DModule(copy)}
3359
3525
  </body>
3360
3526
  </html>
3361
3527
  `;