tina4ruby 3.10.32 → 3.10.38

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.
@@ -3,6 +3,9 @@
3
3
  require "json"
4
4
  require "digest"
5
5
  require "tmpdir"
6
+ require "net/http"
7
+ require "uri"
8
+ require_relative "metrics"
6
9
 
7
10
  module Tina4
8
11
  # Thread-safe in-memory message log for dev dashboard
@@ -423,6 +426,15 @@ module Tina4
423
426
  body = read_json_body(env)
424
427
  name = (body && body["name"]) || ""
425
428
  json_response(gallery_deploy(name))
429
+ when ["GET", "/__dev/api/version-check"]
430
+ json_response(version_check_payload)
431
+ when ["GET", "/__dev/api/metrics"]
432
+ json_response(Tina4::Metrics.quick_metrics)
433
+ when ["GET", "/__dev/api/metrics/full"]
434
+ json_response(Tina4::Metrics.full_analysis)
435
+ when ["GET", "/__dev/api/metrics/file"]
436
+ file_path = (query_param(env, "path") || "").to_s
437
+ json_response(Tina4::Metrics.file_detail(file_path))
426
438
  else
427
439
  nil
428
440
  end
@@ -451,6 +463,27 @@ module Tina4
451
463
  [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
452
464
  end
453
465
 
466
+ def version_check_payload
467
+ current = Tina4::VERSION
468
+ latest = current
469
+ begin
470
+ uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
471
+ http = Net::HTTP.new(uri.host, uri.port)
472
+ http.use_ssl = true
473
+ http.open_timeout = 5
474
+ http.read_timeout = 5
475
+ req = Net::HTTP::Get.new(uri)
476
+ resp = http.request(req)
477
+ if resp.is_a?(Net::HTTPSuccess)
478
+ data = JSON.parse(resp.body)
479
+ latest = data["version"] || current
480
+ end
481
+ rescue StandardError
482
+ # Offline or timeout — return current as latest
483
+ end
484
+ { current: current, latest: latest }
485
+ end
486
+
454
487
  def serve_dashboard
455
488
  [200, { "content-type" => "text/html; charset=utf-8" }, [render_dashboard]]
456
489
  end
@@ -810,6 +843,7 @@ module Tina4
810
843
  .dev-header {
811
844
  background: var(--surface); border-bottom: 1px solid var(--border);
812
845
  padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem;
846
+ position: sticky; top: 0; z-index: 100;
813
847
  }
814
848
  .dev-header h1 { font-size: 1rem; font-weight: 600; }
815
849
  .dev-header .badge {
@@ -819,6 +853,7 @@ module Tina4
819
853
  .dev-tabs {
820
854
  display: flex; gap: 0; background: var(--surface);
821
855
  border-bottom: 1px solid var(--border); overflow-x: auto;
856
+ position: sticky; top: 2.75rem; z-index: 100;
822
857
  }
823
858
  .dev-tab {
824
859
  padding: 0.6rem 1rem; cursor: pointer; font-size: 0.8rem;
@@ -832,10 +867,10 @@ module Tina4
832
867
  background: var(--border); color: var(--muted); padding: 0.1rem 0.4rem;
833
868
  border-radius: 0.75rem; font-size: 0.65rem; margin-left: 0.25rem;
834
869
  }
835
- .dev-content { padding: 1rem; max-width: 1400px; }
870
+ .dev-content { padding: 0.25rem; }
836
871
  .dev-panel {
837
872
  background: var(--surface); border: 1px solid var(--border);
838
- border-radius: var(--radius); overflow: hidden;
873
+ border-radius: var(--radius); overflow: visible;
839
874
  }
840
875
  .dev-panel-header {
841
876
  padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
@@ -952,6 +987,7 @@ module Tina4
952
987
  <button class="dev-tab" onclick="showTab('system', event)">System</button>
953
988
  <button class="dev-tab" onclick="showTab('tools', event)">Tools</button>
954
989
  <button class="dev-tab" onclick="showTab('connections', event)">Connections</button>
990
+ <button class="dev-tab" onclick="showTab('metrics', event)">Metrics</button>
955
991
  <button class="dev-tab" onclick="showTab('chat', event)">Tina4</button>
956
992
  </div>
957
993
 
@@ -1289,6 +1325,30 @@ module Tina4
1289
1325
  });
1290
1326
  </script>
1291
1327
 
1328
+ <!-- Metrics Panel -->
1329
+ <div id="panel-metrics" class="dev-panel hidden">
1330
+ <div class="dev-panel-header">
1331
+ <h2>Code Metrics</h2>
1332
+ <div>
1333
+ <button class="btn btn-sm" onclick="loadAllMetrics()">Refresh</button>
1334
+ </div>
1335
+ </div>
1336
+ <div id="metrics-bubble" style="margin:1rem;"></div>
1337
+ <div id="metrics-drilldown" style="margin:0 1rem;display:none;"></div>
1338
+ <div id="metrics-quick" class="sys-grid"></div>
1339
+ <div id="metrics-largest" style="margin-top:1rem;"></div>
1340
+ <div id="metrics-tables" style="margin-top:1rem;padding:0 1rem 1rem;overflow-x:auto;">
1341
+ <h3 style="margin:1rem 0 0.5rem;color:var(--primary);">File Analysis</h3>
1342
+ <div id="metrics-heatmap"></div>
1343
+ <h3 style="margin:1rem 0 0.5rem;color:var(--primary);">Most Complex Functions</h3>
1344
+ <div id="metrics-complex"></div>
1345
+ <h3 style="margin:1rem 0 0.5rem;color:var(--primary);">Coupling Analysis</h3>
1346
+ <div id="metrics-coupling"></div>
1347
+ <h3 style="margin:1rem 0 0.5rem;color:var(--primary);">Violations</h3>
1348
+ <div id="metrics-violations"></div>
1349
+ </div>
1350
+ </div>
1351
+
1292
1352
  <!-- Chat Panel (Tina4) -->
1293
1353
  <div id="panel-chat" class="dev-panel hidden">
1294
1354
  <div class="dev-panel-header">
@@ -1318,6 +1378,225 @@ module Tina4
1318
1378
 
1319
1379
  <script src="/__dev/js/tina4-dev-admin.min.js"></script>
1320
1380
  <script>
1381
+ // ── Metrics Panel JS ──
1382
+ var _metricsFullData=null;
1383
+ function miColor(mi){
1384
+ if(mi>=60) return 'rgb('+(Math.round(34+(1-((mi-60)/40))*186))+','+(Math.round(197-(1-((mi-60)/40))*50))+',0)';
1385
+ if(mi>=30) return 'rgb('+(Math.round(220+((60-mi)/30)*19))+','+(Math.round(180-((60-mi)/30)*112))+',0)';
1386
+ return 'rgb(239,'+(Math.round(68-mi*2))+',0)';
1387
+ }
1388
+ function renderBubbleChart(files){
1389
+ var container=document.getElementById('metrics-bubble');
1390
+ if(!files||!files.length){container.innerHTML='<p style="color:var(--muted);padding:1rem">No files to analyze</p>';return;}
1391
+ var W=container.offsetWidth||900,H=Math.max(450,Math.min(650,W*0.45));
1392
+ var maxLoc=Math.max.apply(null,files.map(function(f){return f.loc}))||1;
1393
+ var minR=14,maxR=Math.min(70,W/10);
1394
+ var sorted=files.slice().sort(function(a,b){return a.loc-b.loc});
1395
+ var cx=W/2,cy=H/2;
1396
+ var bubbles=[];
1397
+ var angle=0,spiralR=0;
1398
+ for(var i=0;i<sorted.length;i++){
1399
+ var f=sorted[i];
1400
+ var r=minR+Math.sqrt(f.loc/maxLoc)*(maxR-minR);
1401
+ var color=miColor(f.maintainability||0);
1402
+ var placed=false;
1403
+ for(var attempt=0;attempt<800;attempt++){
1404
+ var px=cx+spiralR*Math.cos(angle);
1405
+ var py=cy+spiralR*Math.sin(angle);
1406
+ var collides=false;
1407
+ for(var j=0;j<bubbles.length;j++){
1408
+ var dx=px-bubbles[j].x,dy=py-bubbles[j].y;
1409
+ if(Math.sqrt(dx*dx+dy*dy)<r+bubbles[j].r+2){collides=true;break;}
1410
+ }
1411
+ if(!collides&&px>r+2&&px<W-r-2&&py>r+25&&py<H-r-2){
1412
+ bubbles.push({x:px,y:py,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});
1413
+ placed=true;break;
1414
+ }
1415
+ angle+=0.2;spiralR+=0.04;
1416
+ }
1417
+ if(!placed){bubbles.push({x:cx+(Math.random()-0.5)*W*0.3,y:cy+(Math.random()-0.5)*H*0.3,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});}
1418
+ }
1419
+ var canvas=document.createElement('canvas');
1420
+ canvas.width=W;canvas.height=H;
1421
+ canvas.style.cssText='display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a';
1422
+ container.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;color:var(--primary)">Code Landscape</h3><span style="font-size:0.7rem;color:var(--muted)">Click a bubble to drill down | Size=LOC | <span style="color:#22c55e">Green</span>=maintainable <span style="color:#eab308">Yellow</span>=moderate <span style="color:#ef4444">Red</span>=needs work</span></div>';
1423
+ container.appendChild(canvas);
1424
+ var ctx=canvas.getContext('2d');
1425
+ var hoveredIdx=-1;
1426
+ var t=0;
1427
+ function draw(){
1428
+ t+=0.016;
1429
+ ctx.clearRect(0,0,W,H);
1430
+ ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1;
1431
+ for(var gx=0;gx<W;gx+=50){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H);ctx.stroke();}
1432
+ for(var gy=0;gy<H;gy+=50){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W,gy);ctx.stroke();}
1433
+ bubbles.forEach(function(b,idx){
1434
+ var ox=Math.sin(t*b.speed+b.angle)*b.drift;
1435
+ var oy=Math.cos(t*b.speed*0.7+b.angle+1)*b.drift*0.6;
1436
+ var bx=b.x+ox,by=b.y+oy;
1437
+ var isHovered=(idx===hoveredIdx);
1438
+ var drawR=isHovered?b.r+4:b.r;
1439
+ if(isHovered){
1440
+ ctx.beginPath();ctx.arc(bx,by,drawR+8,0,Math.PI*2);
1441
+ ctx.fillStyle='rgba(255,255,255,0.08)';ctx.fill();
1442
+ }
1443
+ ctx.beginPath();ctx.arc(bx,by,drawR,0,Math.PI*2);
1444
+ ctx.fillStyle=b.color;ctx.globalAlpha=isHovered?0.95:0.7;ctx.fill();
1445
+ ctx.globalAlpha=1;ctx.strokeStyle=b.color;ctx.lineWidth=isHovered?2.5:1.5;ctx.stroke();
1446
+ var name=b.f.path.split('/').pop().replace('.rb','');
1447
+ if(drawR>16){
1448
+ var fs=Math.max(8,Math.min(13,drawR*0.38));
1449
+ ctx.fillStyle='#fff';ctx.font='600 '+fs+'px monospace';ctx.textAlign='center';
1450
+ ctx.fillText(name,bx,by-2);
1451
+ ctx.fillStyle='rgba(255,255,255,0.65)';ctx.font=(fs-1)+'px monospace';
1452
+ ctx.fillText(b.f.loc+' LOC',bx,by+fs);
1453
+ if(isHovered&&drawR>25){
1454
+ ctx.fillStyle='rgba(255,255,255,0.5)';ctx.font=(fs-2)+'px monospace';
1455
+ ctx.fillText('CC:'+b.f.complexity+' MI:'+b.f.maintainability,bx,by+fs*2);
1456
+ }
1457
+ }
1458
+ b._drawX=bx;b._drawY=by;b._drawR=drawR;
1459
+ });
1460
+ var totalLoc=0,totalFiles=bubbles.length;
1461
+ bubbles.forEach(function(b){totalLoc+=b.f.loc});
1462
+ var avgMI=bubbles.reduce(function(s,b){return s+b.f.maintainability},0)/totalFiles;
1463
+ ctx.fillStyle='rgba(255,255,255,0.35)';ctx.font='11px monospace';ctx.textAlign='right';
1464
+ ctx.fillText(totalFiles+' files | '+totalLoc.toLocaleString()+' LOC | Avg MI: '+avgMI.toFixed(1),W-12,H-10);
1465
+ window._metricsAnimFrame=requestAnimationFrame(draw);
1466
+ }
1467
+ draw();
1468
+ canvas.addEventListener('mousemove',function(e){
1469
+ var rect=canvas.getBoundingClientRect();
1470
+ var mx=e.clientX-rect.left,my=e.clientY-rect.top;
1471
+ hoveredIdx=-1;
1472
+ for(var i=bubbles.length-1;i>=0;i--){
1473
+ var b=bubbles[i];
1474
+ var dx=mx-b._drawX,dy=my-b._drawY;
1475
+ if(Math.sqrt(dx*dx+dy*dy)<=b._drawR){hoveredIdx=i;break;}
1476
+ }
1477
+ canvas.style.cursor=hoveredIdx>=0?'pointer':'default';
1478
+ });
1479
+ canvas.addEventListener('mouseleave',function(){hoveredIdx=-1;});
1480
+ canvas.addEventListener('click',function(e){
1481
+ if(hoveredIdx<0)return;
1482
+ var f=bubbles[hoveredIdx].f;
1483
+ drillDownFile(f.path);
1484
+ });
1485
+ }
1486
+ function drillDownFile(path){
1487
+ var dd=document.getElementById('metrics-drilldown');
1488
+ dd.style.display='block';
1489
+ dd.innerHTML='<div class="dev-panel" style="margin-bottom:1rem"><div class="dev-panel-header"><h2>'+path+'</h2><button class="btn btn-sm" onclick="document.getElementById(&#39;metrics-drilldown&#39;).style.display=&#39;none&#39;">Close</button></div><div class="p-md"><p style="color:var(--muted)">Loading file analysis...</p></div></div>';
1490
+ fetch('/__dev/api/metrics/file?path='+encodeURIComponent(path)).then(function(r){return r.json()}).then(function(d){
1491
+ if(d.error){dd.querySelector('.p-md').innerHTML='<p style="color:var(--danger)">'+d.error+'</p>';return;}
1492
+ var html='<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0.5rem;margin-bottom:1rem">';
1493
+ html+='<div class="sys-card"><div class="label">LOC</div><div class="value">'+d.loc+'</div></div>';
1494
+ html+='<div class="sys-card"><div class="label">Total Lines</div><div class="value">'+d.total_lines+'</div></div>';
1495
+ html+='<div class="sys-card"><div class="label">Classes</div><div class="value">'+d.classes+'</div></div>';
1496
+ html+='<div class="sys-card"><div class="label">Functions</div><div class="value">'+(d.functions?d.functions.length:0)+'</div></div>';
1497
+ html+='<div class="sys-card"><div class="label">Imports</div><div class="value">'+(d.imports?d.imports.length:0)+'</div></div>';
1498
+ html+='</div>';
1499
+ if(d.functions&&d.functions.length){
1500
+ html+='<h3 style="margin:0.5rem 0;color:var(--primary);font-size:0.85rem">Cyclomatic Complexity by Function</h3>';
1501
+ var maxCC=Math.max.apply(null,d.functions.map(function(f){return f.complexity}))||1;
1502
+ html+='<div style="display:flex;flex-direction:column;gap:4px">';
1503
+ d.functions.forEach(function(f){
1504
+ var pct=Math.max(3,f.complexity/maxCC*100);
1505
+ var color=f.complexity>20?'#ef4444':f.complexity>10?'#eab308':f.complexity>5?'#3b82f6':'#22c55e';
1506
+ html+='<div style="display:flex;align-items:center;gap:8px;font-size:0.75rem;font-family:var(--mono)">';
1507
+ html+='<span style="width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)" title="'+f.name+'">'+f.name+'</span>';
1508
+ html+='<div style="flex:1;height:16px;background:var(--bg);border-radius:3px;overflow:hidden;position:relative">';
1509
+ html+='<div style="width:'+pct+'%;height:100%;background:'+color+';border-radius:3px;transition:width 0.3s"></div>';
1510
+ html+='</div>';
1511
+ html+='<span style="width:70px;text-align:right;color:'+color+';font-weight:600">CC:'+f.complexity+'</span>';
1512
+ html+='<span style="width:60px;text-align:right;color:var(--muted)">'+f.loc+' LOC</span>';
1513
+ html+='<span style="width:30px;text-align:right;color:var(--muted)">L'+f.line+'</span>';
1514
+ html+='</div>';
1515
+ });
1516
+ html+='</div>';
1517
+ }
1518
+ if(d.imports&&d.imports.length){
1519
+ html+='<h3 style="margin:0.75rem 0 0.25rem;color:var(--primary);font-size:0.85rem">Dependencies</h3>';
1520
+ html+='<div style="display:flex;flex-wrap:wrap;gap:4px">';
1521
+ d.imports.forEach(function(imp){
1522
+ html+='<span style="padding:2px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:0.7rem;font-family:var(--mono)">'+imp+'</span>';
1523
+ });
1524
+ html+='</div>';
1525
+ }
1526
+ dd.querySelector('.p-md').innerHTML=html;
1527
+ }).catch(function(e){
1528
+ dd.querySelector('.p-md').innerHTML='<p style="color:var(--danger)">Error: '+e.message+'</p>';
1529
+ });
1530
+ dd.scrollIntoView({behavior:'smooth',block:'start'});
1531
+ }
1532
+ function loadAllMetrics(){
1533
+ if(window._metricsAnimFrame)cancelAnimationFrame(window._metricsAnimFrame);
1534
+ var el=document.getElementById('metrics-quick');
1535
+ el.innerHTML='<div class="sys-card"><div class="value">Loading...</div></div>';
1536
+ fetch('/__dev/api/metrics').then(function(r){return r.json()}).then(function(d){
1537
+ if(d.error){el.innerHTML='<div class="sys-card"><div class="value" style="color:var(--danger)">'+d.error+'</div></div>';return;}
1538
+ el.innerHTML=
1539
+ '<div class="sys-card"><div class="label">Ruby Files</div><div class="value">'+d.file_count+'</div></div>'+
1540
+ '<div class="sys-card"><div class="label">Lines of Code</div><div class="value">'+d.total_loc.toLocaleString()+'</div></div>'+
1541
+ '<div class="sys-card"><div class="label">Comment Lines</div><div class="value">'+d.total_comment.toLocaleString()+'</div></div>'+
1542
+ '<div class="sys-card"><div class="label">Blank Lines</div><div class="value">'+d.total_blank.toLocaleString()+'</div></div>'+
1543
+ '<div class="sys-card"><div class="label">Classes</div><div class="value">'+d.classes+'</div></div>'+
1544
+ '<div class="sys-card"><div class="label">Functions</div><div class="value">'+d.functions+'</div></div>'+
1545
+ '<div class="sys-card"><div class="label">Routes</div><div class="value">'+d.route_count+'</div></div>'+
1546
+ '<div class="sys-card"><div class="label">ORM Models</div><div class="value">'+d.orm_count+'</div></div>'+
1547
+ '<div class="sys-card"><div class="label">Templates</div><div class="value">'+d.template_count+'</div></div>'+
1548
+ '<div class="sys-card"><div class="label">Migrations</div><div class="value">'+d.migration_count+'</div></div>';
1549
+ }).catch(function(e){el.innerHTML='<div class="sys-card"><div class="value" style="color:var(--danger)">Error: '+e.message+'</div></div>';});
1550
+ document.getElementById('metrics-bubble').innerHTML='<p style="color:var(--muted);padding:1rem">Analyzing codebase...</p>';
1551
+ fetch('/__dev/api/metrics/full').then(function(r){return r.json()}).then(function(d){
1552
+ _metricsFullData=d;
1553
+ if(d.error){document.getElementById('metrics-bubble').innerHTML='<p style="color:var(--danger);padding:1rem">'+d.error+'</p>';return;}
1554
+ renderBubbleChart(d.file_metrics);
1555
+ var hm=document.getElementById('metrics-heatmap');
1556
+ var rows=d.file_metrics.map(function(f){
1557
+ var color=miColor(f.maintainability);
1558
+ var barW=Math.max(2,Math.min(100,f.maintainability));
1559
+ return '<tr style="cursor:pointer" onclick="drillDownFile(&#39;'+f.path+'&#39;)"><td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:'+color+';margin-right:6px"></span>'+f.path+'</td><td>'+f.loc+'</td><td>'+f.complexity+'</td><td>'+f.avg_complexity+'</td><td><div style="display:flex;align-items:center;gap:6px"><div style="width:'+barW+'px;height:6px;border-radius:3px;background:'+color+'"></div><span>'+f.maintainability+'</span></div></td><td>'+f.instability+'</td></tr>';
1560
+ }).join('');
1561
+ hm.innerHTML='<table style="width:100%"><thead><tr><th>File</th><th>LOC</th><th>CC</th><th>Avg CC</th><th>MI</th><th>Instab.</th></tr></thead><tbody>'+rows+'</tbody></table>';
1562
+ var cf=document.getElementById('metrics-complex');
1563
+ var frows=d.most_complex_functions.map(function(f){
1564
+ var color=f.complexity>20?'#ef4444':f.complexity>10?'#eab308':'#22c55e';
1565
+ return '<tr style="cursor:pointer" onclick="drillDownFile(&#39;'+f.file+'&#39;)"><td><span style="color:'+color+';font-weight:bold">'+f.complexity+'</span></td><td>'+f.name+'</td><td>'+f.file+':'+f.line+'</td><td>'+f.loc+'</td></tr>';
1566
+ }).join('');
1567
+ cf.innerHTML='<table style="width:100%"><thead><tr><th>CC</th><th>Function</th><th>File</th><th>LOC</th></tr></thead><tbody>'+frows+'</tbody></table>';
1568
+ var cp=document.getElementById('metrics-coupling');
1569
+ var crows=d.file_metrics.filter(function(f){return f.coupling_afferent>0||f.coupling_efferent>0}).map(function(f){
1570
+ return '<tr style="cursor:pointer" onclick="drillDownFile(&#39;'+f.path+'&#39;)"><td>'+f.path+'</td><td>'+f.coupling_afferent+'</td><td>'+f.coupling_efferent+'</td><td>'+f.instability+'</td></tr>';
1571
+ }).join('');
1572
+ cp.innerHTML=crows?'<table style="width:100%"><thead><tr><th>File</th><th>Ca (in)</th><th>Ce (out)</th><th>Instability</th></tr></thead><tbody>'+crows+'</tbody></table>':'<p style="color:var(--muted)">No coupling data</p>';
1573
+ var vl=document.getElementById('metrics-violations');
1574
+ if(d.violations&&d.violations.length){
1575
+ var vrows=d.violations.map(function(v){
1576
+ var icon=v.type==='error'?'&#9888;':'&#9432;';
1577
+ var color=v.type==='error'?'#ef4444':'#eab308';
1578
+ return '<tr style="cursor:pointer" onclick="drillDownFile(&#39;'+v.file+'&#39;)"><td style="color:'+color+'">'+icon+'</td><td>'+v.message+'</td><td>'+v.file+(v.line?':'+v.line:'')+'</td></tr>';
1579
+ }).join('');
1580
+ vl.innerHTML='<table style="width:100%"><thead><tr><th></th><th>Issue</th><th>Location</th></tr></thead><tbody>'+vrows+'</tbody></table>';
1581
+ }else{
1582
+ vl.innerHTML='<p style="color:#22c55e">&#10003; No violations found</p>';
1583
+ }
1584
+ }).catch(function(e){
1585
+ document.getElementById('metrics-bubble').innerHTML='<p style="color:var(--danger);padding:1rem">Error: '+e.message+'</p>';
1586
+ });
1587
+ }
1588
+ var _metricsLoaded=false;
1589
+ var _origShowTab=typeof showTab==='function'?showTab:null;
1590
+ if(_origShowTab){
1591
+ showTab=function(name){
1592
+ _origShowTab(name);
1593
+ if(name==='metrics'&&!_metricsLoaded){_metricsLoaded=true;loadAllMetrics();}
1594
+ };
1595
+ }
1596
+ var metricsTab=document.querySelector('[onclick*="metrics"]');
1597
+ if(metricsTab)metricsTab.addEventListener('click',function(){if(!_metricsLoaded){_metricsLoaded=true;loadAllMetrics();}});
1598
+ </script>
1599
+ <script>
1321
1600
  // Self-diagnostic — detect if the external JS failed to load
1322
1601
  (function() {
1323
1602
  if (typeof showTab !== 'function') {
data/lib/tina4/frond.rb CHANGED
@@ -414,11 +414,23 @@ module Tina4
414
414
  end
415
415
 
416
416
  def render_with_blocks(parent_source, context, child_blocks)
417
+ engine = self
417
418
  result = parent_source.gsub(BLOCK_RE) do
418
419
  name = Regexp.last_match(1)
419
- default_content = Regexp.last_match(2)
420
- block_source = child_blocks.fetch(name, default_content)
421
- render_tokens(tokenize(block_source), context)
420
+ parent_content = Regexp.last_match(2)
421
+ block_source = child_blocks.fetch(name, parent_content)
422
+
423
+ # Make parent() and super() available inside child blocks
424
+ rendered_parent = nil
425
+ get_parent = lambda do
426
+ rendered_parent ||= Tina4::SafeString.new(
427
+ engine.send(:render_tokens, tokenize(parent_content), context)
428
+ )
429
+ rendered_parent
430
+ end
431
+
432
+ block_ctx = context.merge("parent" => get_parent, "super" => get_parent)
433
+ render_tokens(tokenize(block_source), block_ctx)
422
434
  end
423
435
  render_tokens(tokenize(result), context)
424
436
  end