@@ -51,44 +51,85 @@ window.ProbeRender = (function () {
5151 + 'html.dark .probe-server-row.probe-row-active{background:#2a3a50 !important}'
5252 + 'html.dark .probe-table thead a{color:#58a6ff !important}'
5353 // Tooltip (hover)
54- + '.probe-tooltip{position:fixed;z-index:9999 ;background:#1c1c1c;color:#e0e0e0;font-family:monospace;font-size:11px;'
55- + 'white-space:pre;padding:8px 10px;border-radius:6px;max-width:500px;max-height:300px ;overflow:auto;'
56- + 'pointer-events:none; box-shadow:0 4px 16px rgba(0,0,0,0.3);line-height:1.4}'
54+ + '.probe-tooltip{position:fixed;z-index:10001 ;background:#1c1c1c;color:#e0e0e0;font-family:monospace;font-size:11px;'
55+ + 'white-space:pre;padding:8px 10px;border-radius:6px;max-width:500px;max-height:60vh ;overflow:auto;'
56+ + 'box-shadow:0 4px 16px rgba(0,0,0,0.3);line-height:1.4}'
5757 + '.probe-tooltip .probe-note{color:#f0c674;font-family:sans-serif;font-weight:600;font-size:11px;margin-bottom:6px;white-space:normal}'
5858 + '.probe-tooltip .probe-label{color:#81a2be;font-family:sans-serif;font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px}'
5959 + '.probe-tooltip .probe-label:not(:first-child){margin-top:8px;padding-top:8px;border-top:1px solid #333}'
6060 // Modal (click)
6161 + '.probe-modal-overlay{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}'
6262 + '.probe-modal{background:#1c1c1c;color:#e0e0e0;font-family:monospace;font-size:12px;white-space:pre;'
63- + 'padding:16px 20px;border-radius:8px;max-width:700px ;max-height:80vh ;overflow:auto;'
63+ + 'padding:16px 20px;border-radius:8px;max-width:90vw ;max-height:85vh ;overflow:auto;'
6464 + 'box-shadow:0 8px 32px rgba(0,0,0,0.5);line-height:1.5;position:relative;min-width:300px}'
6565 + '.probe-modal .probe-note{color:#f0c674;font-family:sans-serif;font-weight:600;font-size:13px;margin-bottom:10px;white-space:normal}'
6666 + '.probe-modal .probe-label{color:#81a2be;font-family:sans-serif;font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px}'
6767 + '.probe-modal .probe-label:not(:first-child){margin-top:12px;padding-top:12px;border-top:1px solid #333}'
6868 + '.probe-modal-close{position:sticky;top:0;float:right;background:none;border:none;color:#808080;font-size:20px;'
6969 + 'cursor:pointer;padding:0 4px;line-height:1;font-family:sans-serif}'
70- + '.probe-modal-close:hover{color:#fff}' ;
70+ + '.probe-modal-close:hover{color:#fff}'
71+ // Sticky first column — light
72+ + '.probe-table .probe-sticky-col{position:sticky;left:0;z-index:2;background:#fff;box-shadow:2px 0 4px rgba(0,0,0,0.06)}'
73+ + '.probe-table thead .probe-sticky-col{z-index:3}'
74+ + 'tr[data-expected-row] .probe-sticky-col{background:#f6f8fa}'
75+ + '.probe-server-row:hover .probe-sticky-col{background:#eef1f5}'
76+ + '.probe-server-row.probe-row-active .probe-sticky-col{background:#c8ddf0}'
77+ // Sticky first column — dark
78+ + 'html.dark .probe-table .probe-sticky-col{background:#1c2128;box-shadow:2px 0 4px rgba(0,0,0,0.2)}'
79+ + 'html.dark tr[data-expected-row] .probe-sticky-col{background:#21262d}'
80+ + 'html.dark .probe-server-row:hover .probe-sticky-col{background:#161b22}'
81+ + 'html.dark .probe-server-row.probe-row-active .probe-sticky-col{background:#2a3a50}'
82+ // Collapsible groups
83+ + '.probe-group-header{cursor:pointer;user-select:none;display:flex;align-items:center;gap:8px}'
84+ + '.probe-group-chevron{display:inline-block;transition:transform 0.2s;font-size:0.8em}'
85+ + '.probe-group-chevron.collapsed{transform:rotate(-90deg)}'
86+ + '.probe-group-body{overflow:hidden;transition:max-height 0.3s ease,opacity 0.3s ease;max-height:10000px;opacity:1}'
87+ + '.probe-group-body.collapsed{max-height:0;opacity:0}'
88+ + '.probe-toggle-all{display:inline-block;padding:4px 12px;font-size:12px;font-weight:600;border-radius:20px;cursor:pointer;'
89+ + 'border:1px solid #d0d7de;background:#f6f8fa;color:#24292f;margin-bottom:8px;transition:all 0.15s}'
90+ + '.probe-toggle-all:hover{background:#eef1f5}'
91+ + 'html.dark .probe-toggle-all{border-color:#30363d;background:#21262d;color:#c9d1d9}'
92+ + 'html.dark .probe-toggle-all:hover{background:#30363d}' ;
7193 var style = document . createElement ( 'style' ) ;
7294 style . textContent = css ;
7395 document . head . appendChild ( style ) ;
7496
97+ // Truncation detection for raw request/response (8192-byte cap)
98+ function isTruncated ( raw ) {
99+ if ( ! raw || raw . indexOf ( '[Truncated' ) !== - 1 ) return false ;
100+ return raw . length > 500 && raw . charAt ( raw . length - 1 ) !== '\n' ;
101+ }
102+
75103 // Tooltip hover handler (delegated)
76104 var tip = null ;
105+ var tipTrigger = null ;
106+ function dismissTip ( ) { if ( tip ) { tip . remove ( ) ; tip = null ; tipTrigger = null ; } }
77107 document . addEventListener ( 'mouseover' , function ( e ) {
108+ // If mouse moved into the tooltip itself, keep it open
109+ if ( tip && tip . contains ( e . target ) ) return ;
78110 var target = e . target . closest ( '[data-tooltip]' ) ;
79- if ( ! target ) return ;
80- if ( tip ) { tip . remove ( ) ; tip = null ; }
111+ if ( ! target ) { dismissTip ( ) ; return ; }
112+ if ( target === tipTrigger ) return ; // already showing for this element
113+ dismissTip ( ) ;
81114 var text = target . getAttribute ( 'data-tooltip' ) ;
82115 if ( ! text ) return ;
116+ tipTrigger = target ;
83117 tip = document . createElement ( 'div' ) ;
84118 tip . className = 'probe-tooltip' ;
85119 var note = target . getAttribute ( 'data-note' ) ;
86120 var req = target . getAttribute ( 'data-request' ) ;
121+ var truncated = isTruncated ( req ) || isTruncated ( text ) ;
87122 var html = '' ;
123+ if ( truncated ) html += '<div style="color:#f0c674;font-family:sans-serif;font-weight:600;font-size:10px;margin-bottom:6px;white-space:normal;">[Truncated \u2014 payload exceeds display limit]</div>' ;
88124 if ( note ) html += '<div class="probe-note">' + escapeAttr ( note ) + '</div>' ;
89125 if ( req ) html += '<div class="probe-label">Request</div>' + escapeAttr ( req ) ;
90126 if ( text ) html += '<div class="probe-label">Response</div>' + escapeAttr ( text ) ;
91127 tip . innerHTML = html ;
128+ // Dismiss when mouse leaves the tooltip
129+ tip . addEventListener ( 'mouseleave' , function ( ev ) {
130+ if ( tipTrigger && tipTrigger . contains ( ev . relatedTarget ) ) return ;
131+ dismissTip ( ) ;
132+ } ) ;
92133 document . body . appendChild ( tip ) ;
93134 var rect = target . getBoundingClientRect ( ) ;
94135 var tipRect = tip . getBoundingClientRect ( ) ;
@@ -102,7 +143,10 @@ window.ProbeRender = (function () {
102143 } ) ;
103144 document . addEventListener ( 'mouseout' , function ( e ) {
104145 var target = e . target . closest ( '[data-tooltip]' ) ;
105- if ( target && tip ) { tip . remove ( ) ; tip = null ; }
146+ if ( ! target || target !== tipTrigger ) return ;
147+ // Don't dismiss if mouse moved into the tooltip
148+ if ( tip && ( e . relatedTarget === tip || tip . contains ( e . relatedTarget ) ) ) return ;
149+ if ( target && tip ) { dismissTip ( ) ; }
106150 } ) ;
107151
108152 // Modal click handler (delegated)
@@ -113,10 +157,12 @@ window.ProbeRender = (function () {
113157 var req = target . getAttribute ( 'data-request' ) ;
114158 if ( ! text && ! req ) return ;
115159 // Dismiss hover tooltip
116- if ( tip ) { tip . remove ( ) ; tip = null ; }
160+ dismissTip ( ) ;
117161
118162 var note = target . getAttribute ( 'data-note' ) ;
163+ var truncated = isTruncated ( req ) || isTruncated ( text ) ;
119164 var html = '<button class="probe-modal-close" title="Close">×</button>' ;
165+ if ( truncated ) html += '<div style="color:#f0c674;font-family:sans-serif;font-weight:600;font-size:12px;margin-bottom:8px;white-space:normal;">[Truncated \u2014 payload exceeds display limit]</div>' ;
120166 if ( note ) html += '<div class="probe-note">' + escapeAttr ( note ) + '</div>' ;
121167 if ( req ) html += '<div class="probe-label">Request</div>' + escapeAttr ( req ) ;
122168 if ( text ) html += '<div class="probe-label">Response</div>' + escapeAttr ( text ) ;
@@ -472,7 +518,9 @@ window.ProbeRender = (function () {
472518 el . innerHTML = html ;
473519 }
474520
475- function renderTable ( targetId , categoryKey , ctx , testIdFilter ) {
521+ var CAT_LABELS = { Compliance : 'Compliance' , Smuggling : 'Smuggling' , MalformedInput : 'Malformed Input' , Normalization : 'Normalization' } ;
522+
523+ function renderTable ( targetId , categoryKey , ctx , testIdFilter , tableLabel ) {
476524 injectScrollStyle ( ) ;
477525 var el = document . getElementById ( targetId ) ;
478526 if ( ! el ) return ;
@@ -500,7 +548,7 @@ window.ProbeRender = (function () {
500548
501549 // Column header row (diagonal labels)
502550 t += '<thead><tr>' ;
503- t += '<th style="padding:4px 8px;text-align:left;vertical-align:bottom;min-width:100px;"></th>' ;
551+ t += '<th class="probe-sticky-col" style="padding:4px 8px;text-align:left;vertical-align:bottom;min-width:100px;"></th>' ;
504552 orderedTests . forEach ( function ( tid , i ) {
505553 var first = lookup [ names [ 0 ] ] [ tid ] ;
506554 var isUnscored = first . scored === false ;
@@ -520,8 +568,8 @@ window.ProbeRender = (function () {
520568 t += '</tr></thead><tbody>' ;
521569
522570 // Expected row
523- t += '<tr style="background:#f6f8fa;">' ;
524- t += '<td style="padding:4px 8px;font-weight:700;font-size:11px;color:#656d76;">Expected</td>' ;
571+ t += '<tr data-expected-row style="background:#f6f8fa;">' ;
572+ t += '<td class="probe-sticky-col" style="padding:4px 8px;font-weight:700;font-size:11px;color:#656d76;">Expected</td>' ;
525573 orderedTests . forEach ( function ( tid ) {
526574 var first = lookup [ names [ 0 ] ] [ tid ] ;
527575 var isUnscored = first . scored === false ;
@@ -534,14 +582,14 @@ window.ProbeRender = (function () {
534582 var serverLangs = { } ;
535583 if ( ctx . servers ) ctx . servers . forEach ( function ( sv ) { serverLangs [ sv . name ] = sv . language ; } ) ;
536584 names . forEach ( function ( n ) {
537- t += '<tr class="probe-server-row">' ;
585+ t += '<tr class="probe-server-row" data-server="' + escapeAttr ( n ) + '" >';
538586 var lang = serverLangs [ n ] ;
539587 var langSuffix = lang ? ' <span style="font-weight:400;color:#656d76;font-size:10px;">(' + lang + ')</span>' : '' ;
540588 var srvUrl = serverUrl ( n ) ;
541589 var srvName = srvUrl
542590 ? '<a href="' + srvUrl + '" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration=\'underline\'" onmouseout="this.style.textDecoration=\'none\'">' + n + '</a>'
543591 : n ;
544- t += '<td style="padding:4px 8px;font-weight:600;font-size:12px;white-space:nowrap;">' + srvName + langSuffix + '</td>' ;
592+ t += '<td class="probe-sticky-col" style="padding:4px 8px;font-weight:600;font-size:12px;white-space:nowrap;">' + srvName + langSuffix + '</td>' ;
545593 orderedTests . forEach ( function ( tid ) {
546594 var r = lookup [ n ] && lookup [ n ] [ tid ] ;
547595 var isUnscored = lookup [ names [ 0 ] ] [ tid ] . scored === false ;
@@ -561,16 +609,132 @@ window.ProbeRender = (function () {
561609 }
562610 el . innerHTML = t ;
563611
564- // Row click-to-highlight (one at a time)
612+ // Row click → detail popup
565613 var rows = el . querySelectorAll ( '.probe-server-row' ) ;
566614 rows . forEach ( function ( row ) {
567615 row . addEventListener ( 'click' , function ( e ) {
568- if ( e . target . closest ( 'a' ) ) return ;
569- var wasActive = row . classList . contains ( 'probe-row-active' ) ;
570- rows . forEach ( function ( r ) { r . classList . remove ( 'probe-row-active' ) ; } ) ;
571- if ( ! wasActive ) row . classList . add ( 'probe-row-active' ) ;
616+ if ( e . target . closest ( 'a' ) || e . target . closest ( '[data-tooltip]' ) ) return ;
617+
618+ var svName = row . getAttribute ( 'data-server' ) ;
619+ if ( ! svName ) return ;
620+
621+ // Build vertical detail table for this server
622+ var sUrl = serverUrl ( svName ) ;
623+ var titleHtml = sUrl
624+ ? '<a href="' + sUrl + '" style="color:#58a6ff;text-decoration:underline;text-underline-offset:2px;">' + escapeAttr ( svName ) + '</a>'
625+ : escapeAttr ( svName ) ;
626+
627+ var pass = 0 , warn = 0 , fail = 0 ;
628+ orderedTests . forEach ( function ( tid ) {
629+ var r = lookup [ svName ] && lookup [ svName ] [ tid ] ;
630+ if ( ! r || r . scored === false ) return ;
631+ if ( r . verdict === 'Pass' ) pass ++ ;
632+ else if ( r . verdict === 'Warn' ) warn ++ ;
633+ else fail ++ ;
634+ } ) ;
635+
636+ var displayLabel = tableLabel || CAT_LABELS [ categoryKey ] || categoryKey ;
637+ var h = '<button class="probe-modal-close" title="Close">×</button>' ;
638+ h += '<div style="font-size:11px;font-weight:600;color:#81a2be;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px;">' + escapeAttr ( displayLabel ) + '</div>' ;
639+ h += '<div style="font-size:15px;font-weight:700;margin-bottom:4px;">' + titleHtml + '</div>' ;
640+ h += '<div style="display:flex;gap:12px;margin-bottom:12px;font-size:12px;font-weight:600;">' ;
641+ h += '<span style="color:' + PASS_BG + ';">' + pass + ' Pass</span>' ;
642+ if ( warn > 0 ) h += '<span style="color:' + WARN_BG + ';">' + warn + ' Warn</span>' ;
643+ if ( fail > 0 ) h += '<span style="color:' + FAIL_BG + ';">' + fail + ' Fail</span>' ;
644+ h += '</div>' ;
645+
646+ h += '<table style="border-collapse:collapse;width:100%;font-size:12px;">' ;
647+ h += '<thead><tr style="border-bottom:1px solid #333;">' ;
648+ h += '<th style="padding:4px 8px;text-align:left;color:#81a2be;">Test</th>' ;
649+ h += '<th style="padding:4px 8px;text-align:center;color:#81a2be;">Expected</th>' ;
650+ h += '<th style="padding:4px 8px;text-align:center;color:#81a2be;">Got</th>' ;
651+ h += '<th style="padding:4px 8px;text-align:left;color:#81a2be;">Description</th>' ;
652+ h += '</tr></thead><tbody>' ;
653+
654+ orderedTests . forEach ( function ( tid ) {
655+ var first = lookup [ names [ 0 ] ] [ tid ] ;
656+ var r = lookup [ svName ] && lookup [ svName ] [ tid ] ;
657+ var isUnscored = first . scored === false ;
658+ var opacity = isUnscored ? 'opacity:0.55;' : '' ;
659+ var shortLabel = tid . replace ( / ^ ( R F C \d + - [ \d . ] + - | C O M P - | S M U G - | M A L - | N O R M - ) / , '' ) ;
660+ var url = testUrl ( tid ) ;
661+ var testLink = url
662+ ? '<a href="' + url + '" style="color:#58a6ff;text-decoration:underline;text-underline-offset:2px;">' + shortLabel + '</a>'
663+ : shortLabel ;
664+ if ( isUnscored ) testLink += '*' ;
665+
666+ var gotCell ;
667+ if ( ! r ) {
668+ gotCell = pill ( SKIP_BG , '\u2014' ) ;
669+ } else {
670+ gotCell = pill ( verdictBg ( r . verdict ) , r . got , r . rawResponse , r . behavioralNote , r . rawRequest ) ;
671+ }
672+
673+ h += '<tr style="border-bottom:1px solid #2a2f38;' + opacity + '">' ;
674+ h += '<td style="padding:4px 8px;font-weight:600;white-space:nowrap;">' + testLink + '</td>' ;
675+ h += '<td style="text-align:center;padding:2px 4px;">' + pill ( EXPECT_BG , first . expected . replace ( / o r c l o s e / g, '/\u2715' ) . replace ( / \/ / g, '/\u200B' ) ) + '</td>' ;
676+ h += '<td style="text-align:center;padding:2px 4px;">' + gotCell + '</td>' ;
677+ h += '<td style="padding:4px 8px;color:#999;white-space:normal;max-width:300px;">' + ( first . description || '' ) + '</td>' ;
678+ h += '</tr>' ;
679+ } ) ;
680+ h += '</tbody></table>' ;
681+
682+ var overlay = document . createElement ( 'div' ) ;
683+ overlay . className = 'probe-modal-overlay' ;
684+ var modal = document . createElement ( 'div' ) ;
685+ modal . className = 'probe-modal' ;
686+ modal . style . maxWidth = '800px' ;
687+ modal . style . whiteSpace = 'normal' ;
688+ modal . innerHTML = h ;
689+ overlay . appendChild ( modal ) ;
690+ document . body . appendChild ( overlay ) ;
691+
692+ modal . querySelector ( '.probe-modal-close' ) . addEventListener ( 'click' , function ( ) { overlay . remove ( ) ; } ) ;
693+ overlay . addEventListener ( 'click' , function ( ev ) { if ( ev . target === overlay ) overlay . remove ( ) ; } ) ;
694+ function onKey ( ev ) {
695+ if ( ev . key === 'Escape' ) { overlay . remove ( ) ; document . removeEventListener ( 'keydown' , onKey ) ; }
696+ }
697+ document . addEventListener ( 'keydown' , onKey ) ;
698+ } ) ;
699+ } ) ;
700+ }
701+
702+ // ── Collapsible-group wiring helper ────────────────────────────
703+ function wireCollapsible ( el , targetId ) {
704+ var headers = el . querySelectorAll ( '.probe-group-header' ) ;
705+ headers . forEach ( function ( hdr ) {
706+ hdr . addEventListener ( 'click' , function ( ) {
707+ var groupId = hdr . getAttribute ( 'data-group' ) ;
708+ var body = document . getElementById ( groupId ) ;
709+ var chevron = hdr . querySelector ( '.probe-group-chevron' ) ;
710+ if ( body ) body . classList . toggle ( 'collapsed' ) ;
711+ if ( chevron ) chevron . classList . toggle ( 'collapsed' ) ;
712+ updateToggleAllLabel ( el , targetId ) ;
572713 } ) ;
573714 } ) ;
715+ var toggleBtn = el . querySelector ( '.probe-toggle-all' ) ;
716+ if ( toggleBtn ) {
717+ toggleBtn . addEventListener ( 'click' , function ( ) {
718+ var bodies = el . querySelectorAll ( '.probe-group-body' ) ;
719+ var chevrons = el . querySelectorAll ( '.probe-group-chevron' ) ;
720+ var allCollapsed = Array . prototype . every . call ( bodies , function ( b ) { return b . classList . contains ( 'collapsed' ) ; } ) ;
721+ bodies . forEach ( function ( b ) {
722+ if ( allCollapsed ) b . classList . remove ( 'collapsed' ) ; else b . classList . add ( 'collapsed' ) ;
723+ } ) ;
724+ chevrons . forEach ( function ( c ) {
725+ if ( allCollapsed ) c . classList . remove ( 'collapsed' ) ; else c . classList . add ( 'collapsed' ) ;
726+ } ) ;
727+ updateToggleAllLabel ( el , targetId ) ;
728+ } ) ;
729+ }
730+ }
731+
732+ function updateToggleAllLabel ( container , targetId ) {
733+ var btn = container . querySelector ( '.probe-toggle-all[data-target="' + targetId + '"]' ) ;
734+ if ( ! btn ) return ;
735+ var bodies = container . querySelectorAll ( '.probe-group-body' ) ;
736+ var allCollapsed = Array . prototype . every . call ( bodies , function ( b ) { return b . classList . contains ( 'collapsed' ) ; } ) ;
737+ btn . textContent = allCollapsed ? 'Expand All' : 'Collapse All' ;
574738 }
575739
576740 // ── Sub-table renderer ─────────────────────────────────────────
@@ -594,17 +758,20 @@ window.ProbeRender = (function () {
594758 allGroups . push ( { key : 'other' , label : 'Other' , testIds : ungrouped } ) ;
595759 }
596760
597- var html = '' ;
761+ var html = '<button class="probe-toggle-all" data-target="' + targetId + '">Collapse All</button> ';
598762 allGroups . forEach ( function ( g ) {
599763 var divId = targetId + '-' + g . key ;
600- html += '<h3 style="margin-top:1.5em;margin-bottom:0.3em;">' + g . label + '</h3>' ;
601- html += '<div id="' + divId + '"></div>' ;
764+ html += '<h3 class="probe-group-header" data-group="' + divId + '" style="margin-top:1.5em;margin-bottom:0.3em;">'
765+ + '<span class="probe-group-chevron">\u25BC</span>' + g . label + '</h3>' ;
766+ html += '<div class="probe-group-body" id="' + divId + '"></div>' ;
602767 } ) ;
603768 el . innerHTML = html ;
769+ var catLabel = CAT_LABELS [ categoryKey ] || categoryKey ;
604770 allGroups . forEach ( function ( g ) {
605771 var divId = targetId + '-' + g . key ;
606- renderTable ( divId , categoryKey , ctx , g . testIds ) ;
772+ renderTable ( divId , categoryKey , ctx , g . testIds , catLabel + ' \u2014 ' + g . label ) ;
607773 } ) ;
774+ wireCollapsible ( el , targetId ) ;
608775 }
609776
610777 // ── Language filter ────────────────────────────────────────────
0 commit comments