PageSourceSearch

https://spectrue.net/js/ui.js?v=M42h_fix17

js spectrue.net collected 2026-10-02 01:05:38 UTC 51,170 bytes, 1,385 lines download raw bytes

1import { T, getCurrentLocale, isExtensionContext } from './i18n.js?v=M42h_fix17';
2import { $, escapeHtml, getPricingUrl } from './helpers/dom.js?v=M42h_fix17';
3import {
4  getTextColorForBg,
5  sanitizeSources,
6  extractDualSummary,
7  renderSimpleSummaryBullets,
8} from './helpers/formatters.js?v=M42h_fix17';
9import {
10  showToast,
11  setStatus,
12  logToOutput,
13  setDisabled,
14  initTooltips,
15  getBadgeForSource,
16  showModal,
17} from './helpers/ui_components.js?v=M42h_fix17';
18import { logEventSafe, bucketizePosition } from './analytics.js?v=M42h_fix17';
19import { reportPresentationModeContext } from './presentation_mode_context.js?v=M42h_fix17';
20import { renderEvidenceSignalBar } from './components/evidence_signal_bar.js?v=M42h_fix17';
21import { createShareLink } from './api.js?v=M42h_fix17';
22
23// Local cache to avoid circular dependency with handlers.js
24let cachedResultPayload = null;
25
26let shareActionsPromise = null;
27function loadShareActions() {
28  if (!shareActionsPromise) {
29    shareActionsPromise = import('./share_actions.js?v=M42h_fix17');
30  }
31  return shareActionsPromise;
32}
33
34// Re-export for backward compatibility if needed by other modules
35export {
36  $,
37  getPricingUrl,
38  showToast,
39  setStatus,
40  logToOutput as log,
41  setDisabled as disable,
42  initTooltips,
43
44  showModal,
45  renderCheckResult,
46  renderLanguageSelector,
47  renderUserMenu,
48  toggleUserMenu,
49  toggleHistoryModal,
50  initDeepToggleListener,
51};
52
53function buildDeepDetails(result) {
54  const claimResults = result?.deep_analysis?.claim_results;
55  if (!Array.isArray(claimResults) || claimResults.length === 0) return [];
56  const claims = Array.isArray(result?.claims) ? result.claims : [];
57  const alignByIndex = claims.length === claimResults.length;
58
59  return claimResults.map((cr, idx) => {
60    const text = alignByIndex
61      ? claims[idx]
62      : cr?.claim_text || cr?.claim_id || `Claim ${idx + 1}`;
63    // sources_used now contains full objects with trust info from backend
64    const sources = Array.isArray(cr?.sources_used)
65      ? cr.sources_used.map((s) => {
66        // If s is a string (old format), convert to object
67        if (typeof s === 'string') {
68          return { url: s, title: s };
69        }
70        // New format: full object with url, domain, title, trust_category, is_trusted
71        return {
72          url: s.url || '',
73          link: s.url || '',
74          title: s.title || s.domain || 'Source',
75          domain: s.domain || '',
76          is_trusted: s.is_trusted || false,
77          trust_category: s.trust_category || null,
78          excerpt: s.citation_text || s.snippet || '',
79        };
80      })
81      : [];
82    const detail = {
83      text,
84      rgba: Array.isArray(cr?.rgba) ? cr.rgba : null,
85      rationale: cr?.explanation || '',
86      sources,
87    };
88    if (cr?.status && cr.status !== 'ok') {
89      detail.error_key = cr?.error?.error_type || 'judge_failed';
90    }
91    return detail;
92  });
93}
94
95function buildDeepClaimVerdicts(result) {
96  const claimResults = result?.deep_analysis?.claim_results;
97  if (!Array.isArray(claimResults) || claimResults.length === 0) return [];
98  const claims = Array.isArray(result?.claims) ? result.claims : [];
99  const alignByIndex = claims.length === claimResults.length;
100
101  return claimResults.map((cr, idx) => {
102    const text = alignByIndex
103      ? claims[idx]
104      : cr?.claim_text || cr?.claim_id || `Claim ${idx + 1}`;
105    // sources_used now contains full objects with trust info from backend
106    const sources = Array.isArray(cr?.sources_used)
107      ? cr.sources_used.map((s) => {
108        if (typeof s === 'string') {
109          return { url: s, title: s };
110        }
111        return {
112          url: s.url || '',
113          link: s.url || '',
114          title: s.title || s.domain || 'Source',
115          domain: s.domain || '',
116          is_trusted: s.is_trusted || false,
117          trust_category: s.trust_category || null,
118          excerpt: s.citation_text || s.snippet || '',
119        };
120      })
121      : [];
122    return {
123      claim_id: cr?.claim_id || '',
124      claim_text: cr?.claim_text || text,
125      text,
126      rgba: Array.isArray(cr?.rgba) ? cr.rgba : null,
127      status: cr?.status || '',
128      sources,
129    };
130  });
131}
132
133
134// --- Popup Logic ---
135
136function togglePopup(sentenceElement, idx) {
137  const popupId = 'popup-' + idx;
138  let wasActive = document.getElementById(popupId) !== null;
139
140  // T1.1: Don't close popup if there's only one result (prevents accidental closing)
141  const totalDetails = cachedResultPayload?.result?.details?.length || 0;
142  if (wasActive && totalDetails === 1) return; // Keep popup open for single result
143
144  document.querySelectorAll('.popup-container').forEach((p) => p.remove());
145
146  if (wasActive) return;
147
148  // Analytics: result_expand_claim
149  try {
150    const res = cachedResultPayload?.result;
151    const isDeep = !!(
152      res?.deep_analysis ||
153      res?.mode === 'deep' ||
154      res?.analysis_mode === 'deep'
155    );
156    logEventSafe('result_expand_claim', {
157      mode: isDeep ? 'deep' : 'standard',
158      position_bucket: bucketizePosition(idx),
159    });
160  } catch (e) { }
161
162  // Use local cache instead of importing lastCheckResult
163
164  const data = cachedResultPayload?.result?.details?.[idx];
165  if (!data) return;
166
167
168  const _cleanedSources = sanitizeSources(data.sources);
169  const sourcesHtml = _cleanedSources.length
170    ? `<ul>${_cleanedSources
171      .map((s) => {
172        let originText;
173        if (s.origin === 'RAG') {
174          originText = T('popup.source_origin_rag');
175        } else if (s.origin === 'GOOGLE_FACT_CHECK') {
176          originText = T('popup.source_origin_google');
177        } else {
178          originText = T('popup.source_origin_web');
179        }
180
181
182        const badge = getBadgeForSource(s, false);
183        const trustedClass = s.is_trusted
184          ? 'trusted-source'
185          : 'untrusted-source';
186
187
188        if (s.link) {
189          return `<li class="${trustedClass}">${badge}<a href="${s.link}" target="_blank" rel="noopener noreferrer">${escapeHtml(s.title)}</a> <span class="source-origin">(${escapeHtml(originText)})</span></li>`;
190        } else {
191          return `<li><span>${escapeHtml(s.title)}</span> <span class="source-origin">(${escapeHtml(originText)})</span></li>`;
192        }
193      })
194      .join('')}</ul>`
195    : `<p>${T('popup.no_sources')}</p>`;
196
197  // Defensive fallback: if rgba is missing or malformed, use neutral defaults
198  const rgba =
199    Array.isArray(data.rgba) && data.rgba.length >= 4
200      ? data.rgba
201      : [0, -1, 0, 0.5];
202  const [r, g, b, a] = rgba;
203  const fmtScore = (v) =>
204    typeof v === 'number' && v >= 0
205      ? `${Math.round(v * 100)}%`
206      : T('popup.not_available') || 'N/A';
207  const barWidth = (v) =>
208    typeof v === 'number' && v >= 0 ? `${v * 100}%` : '0%';
209  const barClass = (v) =>
210    typeof v === 'number' && v >= 0 ? '' : 'score-bar-na';
211  // Use per-claim rationale if available (deep mode), fall back to global summary
212  const claimRationale = (data.rationale || '').trim();
213  const globalExpert = cachedResultPayload?.result ? extractDualSummary(cachedResultPayload.result) : null;
214  const expertText = claimRationale || globalExpert?.expert || T('popup.no_rationale') || 'No detailed summary available.';
215  const summaryTitle = T('popup.rationale_title') || 'Summary';
216
217  /** Format LLM explanation for display: localize markers, add line breaks */
218  const formatExplanation = (text) => {
219    let s = escapeHtml(text);
220    // Replace raw LLM prompt artifact 'simple_summary:' with localized heading
221    const summaryLabel = T('popup.simple_summary_title') || 'Summary';
222    s = s.replace(/\bsimple_summary\s*:/gi, `<strong>${escapeHtml(summaryLabel)}:</strong>`);
223    // Convert newlines to <br> for readable formatting
224    s = s.replace(/\n/g, '<br>');
225    return s;
226  };
227
228  const popupInnerHtml = `
229        <h4>${T('popup.details_title')}</h4>
230        <div class="score-bar-container">
231            <span>${T('popup.danger_label')} ${fmtScore(r)}</span>
232            <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.danger_tooltip')}"></button>
233            <div class="score-bar ${barClass(r)}"><div style="width: ${barWidth(r)}; background-color: rgba(255, 0, 0, 0.7);"></div></div>
234        </div>
235        ${renderEvidenceSignalBar({
236    gScore: g,
237    compact: true,
238    infoTooltip: T('info.veracity_tooltip'),
239    titleKey: 'popup.veracity_label',
240    strengthPrefixKey: 'report.signal_strength',
241  })}
242        <div class="score-bar-container">
243            <span>${T('popup.honesty_label')} ${fmtScore(b)}</span>
244            <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.honesty_tooltip')}"></button>
245            <div class="score-bar ${barClass(b)}"><div style="width: ${barWidth(b)}; background-color: rgba(0, 0, 255, 0.7);"></div></div>
246        </div>
247         <div class="score-bar-container">
248            <span>${T('popup.confidence_label')} ${fmtScore(a)}</span>
249            <button type="button" class="info-btn" aria-label="Info" data-tooltip="${T('info.confidence_tooltip')}"></button>
250            <div class="score-bar ${barClass(a)}"><div style="width: ${barWidth(a)}; background-color: rgba(128, 128, 128, 0.7);"></div></div>
251        </div>
252      <div class="report-summary-card expert-summary popup-rationale" style="margin-top: 20px; margin-bottom: 20px;">
253        <h4>${escapeHtml(summaryTitle)}</h4>
254        <p class="report-expert-summary">${formatExplanation(expertText)}</p>
255      </div>
256      <details class="claim-sources">
257        <summary>${T('popup.sources_title') || 'Sources'} (${_cleanedSources.length})</summary>
258        ${sourcesHtml}
259      </details>
260    `;
261
262  const newPopupContainer = document.createElement('div');
263  newPopupContainer.id = popupId;
264  newPopupContainer.className = 'popup-container';
265  newPopupContainer.innerHTML = `<div class="popup">${popupInnerHtml}</div>`;
266  sentenceElement.parentElement.after(newPopupContainer);
267}
268
269window.togglePopup = togglePopup;
270
271// New function to handle user menu rendering, including presentation mode logic
272
273
274
275function renderCheckResult(payload) {
276  // Update local cache whenever we render
277  cachedResultPayload = payload;
278
279  const box = document.getElementById('check-out');
280  if (!box) return;
281  box.style.display = 'block';
282
283  const res = payload?.result || null;
284  const status = payload?.status || 'unknown';
285  const parsedText = payload?.parsed_text || null; // M8.2: Backend-parsed text
286
287  if (res?.error_key) {
288    let key = res.error_key;
289    // Normalize keys: 'app.error_foo' -> 'foo', 'app.error.foo' -> 'foo'
290    if (key.startsWith('app.error_')) {
291      key = key.replace('app.error_', '');
292    } else if (key.startsWith('app.error.')) {
293      key = key.replace('app.error.', '');
294    }
295
296    let message = T(`error.${key}`, res);
297    // Fallback if translation missing
298    if (message.startsWith('error.')) {
299      // Try exact key if normalization failed to find a match
300      if (key !== res.error_key) {
301        const directMsg = T(`error.${res.error_key}`);
302        if (!directMsg.startsWith('error.')) message = directMsg;
303      }
304    }
305
306    if (message.startsWith('error.')) {
307      message = T('error.generic_backend_error', { error: res.error_key });
308    }
309    box.innerHTML = `<div class="loader"><span>${escapeHtml(message)}</span></div>`;
310    return;
311  }
312
313  // Check for deep mode results (needs to be accessible throughout the function)
314  const hasDeepResults =
315    Array.isArray(res?.deep_analysis?.claim_results) &&
316    res.deep_analysis.claim_results.length > 0;
317
318  if (status === 'done' && res) {
319    if (hasDeepResults) {
320      const deepDetails = buildDeepDetails(res);
321      if (deepDetails.length) {
322        res.details = deepDetails;
323      }
324      const deepVerdicts = buildDeepClaimVerdicts(res);
325      if (deepVerdicts.length) {
326        res.claim_verdicts = deepVerdicts;
327      }
328    } else {
329      if (!Array.isArray(res.details) || res.details.length === 0) {
330        const deepDetails = buildDeepDetails(res);
331        if (deepDetails.length) {
332          res.details = deepDetails;
333        }
334      }
335      if (
336        !Array.isArray(res.claim_verdicts) ||
337        res.claim_verdicts.length === 0
338      ) {
339        const deepVerdicts = buildDeepClaimVerdicts(res);
340        if (deepVerdicts.length) {
341          res.claim_verdicts = deepVerdicts;
342        }
343      }
344    }
345  }
346
347  if (status === 'done' && res?.details) {
348    let finalHtml = `<h3>${res.article_title ? escapeHtml(res.article_title) : T('results.title')}</h3>`;
349
350    // M8.2: Show parsed text metadata if available
351    if (parsedText) {
352      const metaInfo = [];
353      if (parsedText.title)
354        metaInfo.push(
355          `<strong>${T('results.parsed_title')}:</strong> ${escapeHtml(parsedText.title)}`
356        );
357      if (parsedText.authors && parsedText.authors.length > 0) {
358        metaInfo.push(
359          `<strong>${T('results.authors')}:</strong> ${escapeHtml(parsedText.authors.join(', '))}`
360        );
361      }
362      if (parsedText.publish_date) {
363        const date = new Date(parsedText.publish_date);
364        metaInfo.push(
365          `<strong>${T('results.published')}:</strong> ${date.toLocaleDateString()}`
366        );
367      }
368
369      if (metaInfo.length > 0) {
370        finalHtml += `<div class="parsed-text-info">${metaInfo.join(' • ')}</div>`;
371      }
372    }
373
374    if (res?.fallback) {
375      finalHtml += `<div class="fallback-notice">${escapeHtml(T('results.fallback_message'))}</div>`;
376    }
377
378    const searchInfo = res?.search || null;
379    if (searchInfo && searchInfo.quality === 'poor') {
380      const currentProvider = searchInfo.provider || 'auto';
381      const suggestedProvider =
382        currentProvider === 'google_cse' ? 'tavily' : 'google_cse';
383      const title =
384        T('results.search_quality_low_title') || 'Low search quality';
385      const body =
386        T('results.search_quality_low_body') ||
387        'The web search results look off-topic. You can retry with an alternative provider.';
388      const btnLabel =
389        suggestedProvider === 'google_cse'
390          ? T('results.retry_with_google') || 'Retry with Google'
391          : T('results.retry_with_tavily') || 'Retry with Tavily';
392      finalHtml += `
393        <div class="search-quality-notice">
394          <div class="search-quality-text">
395            <strong>${escapeHtml(title)}</strong><br/>
396            <span>${escapeHtml(body)}</span>
397          </div>
398          <button id="retry-search-button" class="button secondary small" data-provider="${escapeHtml(
399        suggestedProvider
400      )}">${escapeHtml(btnLabel)}</button>
401        </div>`;
402    }
403
404    if (searchInfo && searchInfo.budget_limited) {
405      const title = T('results.budget_limited_title') || 'Limited by budget';
406      const body =
407        T('results.budget_limited_body') ||
408        'Some optional search steps were skipped to fit your current credit balance. The result may be less accurate.';
409      finalHtml += `
410        <div class="search-quality-notice">
411          <div class="search-quality-text">
412            <strong>${escapeHtml(title)}</strong><br />
413            <span>${escapeHtml(body)}</span>
414          </div>
415        </div>`;
416    }
417
418    // T1.2: Display anchor claim (main claim being verified)
419    const anchorClaim = res?.anchor_claim;
420    // M140: Implementation of Dual Presentation Modes
421    const mode = reportPresentationModeContext.getMode();
422    const isDeep = !!(
423      res?.deep_analysis ||
424      res?.mode === 'deep' ||
425      res?.analysis_mode === 'deep'
426    );
427    const isExpert = mode === 'expert' || isDeep;
428    const dualSummary = extractDualSummary(res);
429
430    if (anchorClaim?.text && !isExpert) {
431      const anchorLabel =
432        T('results.anchor_claim_label') || 'Main claim being verified';
433      finalHtml += `
434        <div class="anchor-claim-notice">
435          <span class="anchor-label">${escapeHtml(anchorLabel)}:</span>
436          <span class="anchor-text">${escapeHtml(anchorClaim.text)}</span>
437        </div>`;
438    }
439
440    // 1. Evidence Signal Bar
441    // 1. Evidence Signal Bar
442    if (!isExpert) {
443      const gScore =
444        Array.isArray(res?.rgba) && res.rgba.length >= 2 ? res.rgba[1] : null;
445      finalHtml += renderEvidenceSignalBar({
446        gScore,
447        titleKey: 'report.evidence_signal',
448        strengthPrefixKey: 'report.signal_strength',
449      });
450    }
451
452    // 2. Summary Section
453    const summaryTitle = T('popup.rationale_title') || 'Summary';
454    if (isExpert) {
455      // Expert Mode: Global expert summary is now in the popup
456    } else {
457      // Default Mode: Bullets
458      const summarySimpleHtml = renderSimpleSummaryBullets(dualSummary.simple);
459      if (summarySimpleHtml) {
460        finalHtml += `
461          <div class="report-summary-card default-summary">
462            <h4>${escapeHtml(summaryTitle)}</h4>
463            ${summarySimpleHtml}
464          </div>
465        `;
466      }
467    }
468
469
470
471    // Show all claims that contributed to final RGBA scoring
472    const anchorText = anchorClaim?.text
473      ? anchorClaim.text.trim().toLowerCase()
474      : null;
475    const claimVerdicts = Array.isArray(res?.claim_verdicts)
476      ? res.claim_verdicts
477      : [];
478    const claimItems = claimVerdicts
479      .map((cv) => {
480        if (!cv) return null;
481        const text = cv.claim_text || cv.claim || cv.text;
482        if (!text) return null;
483        if (anchorText && text.trim().toLowerCase() === anchorText) return null; // avoid duplicating anchor claim
484        const verdictLabel = (cv.verdict || cv.status || '').toString();
485        const verdictClass = verdictLabel.toLowerCase();
486        const score =
487          typeof cv.verdict_score === 'number' && cv.verdict_score >= 0
488            ? Math.round(cv.verdict_score * 100)
489            : null;
490        const claimId = cv.claim_id || '';
491
492        // Per-claim RGBA
493        const rgba = cv.rgba || [0, 0, 0, 0.3];
494        const [r, g, b, a] = rgba;
495        const barStyle = (v) =>
496          `width: ${Math.max(0, Math.min(100, v * 100))}%; `;
497
498        // Per-claim Sources
499        const _cleanedSources = sanitizeSources(cv.sources);
500        const sourcesHtml = _cleanedSources.length
501          ? `<ul class="claim-sources-list">${_cleanedSources
502            .map((s) => {
503              const domain = escapeHtml(s.domain || s.title || 'Source');
504
505              // Determine badge (simplified for claim card view)
506              let icon = '🌐'; // Default Web
507              let tooltip = 'Web';
508
509              if (s.trust_category) {
510                // Map known categories to icons directly
511                const badgeMap = {
512                  science_and_health: '🔬',
513                  ukraine_imi_whitelist: '🇺🇦',
514                  fact_checking_ifcn: '✅',
515                  technology: '💻',
516                  russia_independent_exiled: '🕊️',
517                  global_news_agencies: '📰',
518                  general_news_western: '🛡️',
519                  europe_tier1: '🇪🇺',
520                  asia_pacific: '🌏',
521                  astronomy_tier_a: '🔭',
522                  astronomy_tier_b: '🔭',
523                  astronomy_tier_c: '🔭',
524                  general_news_ukraine_broad: '🇺🇦',
525                  finance_tier1: '📈',
526                  education_tier1: '🎓',
527                  law_tier1: '⚖️',
528                  international_public_bodies: '🏛️',
529                };
530                if (badgeMap[s.trust_category]) {
531                  icon = badgeMap[s.trust_category];
532                  tooltip = s.trust_category; // Simplified label
533                }
534              } else if (s.is_trusted) {
535                icon = '🛡️';
536                tooltip = 'Trusted';
537              } else {
538                // Check social
539                const d = (s.domain || '').toLowerCase();
540                if (
541                  [
542                    'facebook',
543                    'twitter',
544                    'x.com',
545                    'instagram',
546                    'youtube',
547                    'tiktok',
548                    'telegram',
549                    'linkedin',
550                    'reddit',
551                  ].some((p) => d.includes(p))
552                ) {
553                  icon = '💬';
554                  tooltip = 'Social';
555                }
556              }
557
558              const badgeHtml = `<span class="source-badge mini" title="${escapeHtml(tooltip)}">${icon}</span>`;
559              const linkUrl = s.link || s.url;
560              const linkLabel = domain || s.title || 'Source';
561              const link = linkUrl
562                ? `${badgeHtml}<a href="${escapeHtml(linkUrl)}" target="_blank" rel="noopener noreferrer">${linkLabel}</a>`
563                : `${badgeHtml}${linkLabel}`;
564
565              // Show excerpt if available
566              let excerptHtml = '';
567              const snippet = s.excerpt || s.content_excerpt;
568              if (snippet) {
569                excerptHtml = `<div class="source-excerpt">${escapeHtml(snippet)}</div>`;
570              }
571
572              return `<li>${link}${excerptHtml}</li>`;
573            })
574            .join('')
575          }</ul>`
576          : '';
577
578        return `
579          <div class="claim-card" data-verdict="${escapeHtml(verdictClass)}">
580            <div class="claim-card-top">
581              ${claimId ? `<span class="claim-card-id">${escapeHtml(claimId)}</span>` : ''}
582              ${verdictLabel ? `<span class="claim-card-verdict">${escapeHtml(verdictLabel)}</span>` : ''}
583              ${score !== null ? `<span class="claim-card-score">${escapeHtml(score)}%</span>` : `<span class="claim-card-score na">N/A</span>`}
584            </div>
585            <div class="claim-card-text">${escapeHtml(text)}</div>
586            
587            <div class="claim-card-rgba">
588                <div class="mini-bar danger" style="${barStyle(r)}" title="${T('popup.danger_label')}"></div>
589                <div class="mini-bar veracity" style="${barStyle(g)}" title="${T('popup.veracity_label')}"></div>
590                <div class="mini-bar honesty" style="${barStyle(b)}" title="${T('popup.honesty_label')}"></div>
591                <div class="mini-bar confidence" style="${barStyle(a)}" title="${T('popup.confidence_label')}"></div>
592            </div>
593            
594            ${sourcesHtml
595            ? `
596            <details class="claim-sources">
597                <summary>${T('popup.sources_title') || 'Sources'} (${_cleanedSources.length})</summary>
598                ${sourcesHtml}
599            </details>
600            `
601            : ''
602          }
603          </div >
604  `;
605      })
606      .filter(Boolean);
607
608    // Only show claims accordion in standard mode (secondary claims)
609    // In deep mode, claims are shown as individual sentences with full RGBA
610    if (claimItems.length) {
611      const claimsLabel =
612        T('results.claims_evaluated_label') || 'Secondary evaluated claims';
613      const claimsCount = claimItems.length;
614      finalHtml += `
615        <details class="claims-list claims-accordion">
616          <summary class="claims-list-header">
617            <span class="claims-list-title">${escapeHtml(claimsLabel)}</span>
618            <span class="claims-list-count">${escapeHtml(String(claimsCount))}</span>
619          </summary>
620          <div class="claims-list-body">
621            ${claimItems.join('')}
622          </div>
623        </details>
624  `;
625    }
626
627
628
629    // Expert Mode: Show per-sentence highlights
630    // Default Mode: Show standard breakdown
631    if (isExpert && res.details) {
632      // Individual sentences with RGBA backgrounds
633      res.details.forEach((d, idx) => {
634        if (d.error_key) {
635          finalHtml += `<div class="sentence-container"><span class="sentence-error">${escapeHtml(d.text)}</span></div> `;
636          return;
637        }
638        const rgba =
639          Array.isArray(d.rgba) && d.rgba.length >= 4 ? d.rgba : [0, 0, 0, 0.35];
640        const [r, g, b, a] = rgba;
641        const textColor = getTextColorForBg(r, g, b);
642        finalHtml += `
643            <div class="sentence-container">
644                    <span class="sentence" 
645                          data-idx="${idx}"
646                          style="background-color: rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${Math.max(a, 0.25)}); color: ${textColor};">
647                        ${escapeHtml(d.text)}
648                    </span>
649            </div> `;
650      });
651    } else {
652      // Default Mode: Simplified view of secondary claims (if any)
653      const secondaryClaims = res.claim_verdicts || [];
654      if (secondaryClaims.length > 0) {
655        finalHtml += `<div class="secondary-claims-section" style="margin-top: 20px;">`;
656        finalHtml += `<h4 style="margin-bottom: 12px;">${T('results.claims_breakdown') || 'Claims Breakdown'}</h4>`;
657        secondaryClaims.forEach((c) => {
658          const rgba = Array.isArray(c.rgba) ? c.rgba : [0, 0, 0, 0.3];
659          const [r, g, b, a] = rgba;
660          const pct = Math.round((g || 0) * 100);
661          finalHtml += `
662            <div class="claim-card-simple">
663              <div class="claim-status-dot" style="background-color: rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1);"></div>
664              <div class="claim-text">${escapeHtml(c.claim_text || c.text || '')}</div>
665              <div class="claim-score-mini">${pct}%</div>
666            </div>`;
667        });
668        finalHtml += `</div>`;
669      }
670    }
671
672    // Show actual verification cost (moved to bottom)
673    // Priority: billing.charged_total (paid) > billing.subsidy_cost (free/subsidized) > cost_summary (raw fallback)
674    let creditsToShow = res?.billing?.charged_total;
675
676    // If charged_total is 0 or missing, try to show subsidy details
677    if (
678      (!creditsToShow || Number(creditsToShow) === 0) &&
679      res?.billing?.subsidy_cost
680    ) {
681      creditsToShow = res.billing.subsidy_cost;
682    }
683
684    const costSummary = res?.billing?.cost_summary || res?.cost_summary;
685    const rawEngineCredits = costSummary?.total_credits;
686
687    // If billing is 0/missing but engine shows non-zero usage, prefer engine usage (estimated/raw)
688    if (
689      (creditsToShow === undefined ||
690        creditsToShow === null ||
691        Number(creditsToShow) === 0) &&
692      rawEngineCredits !== undefined &&
693      rawEngineCredits !== null
694    ) {
695      const n = Number(rawEngineCredits);
696      if (!Number.isNaN(n) && n > 0) {
697        creditsToShow = Math.ceil(n);
698      }
699    }
700
701    // Final fallback: only if billing info is missing completely
702    if (creditsToShow === undefined || creditsToShow === null) {
703      creditsToShow = rawEngineCredits;
704      if (creditsToShow !== undefined)
705        creditsToShow = Math.ceil(Number(creditsToShow));
706    }
707
708    if (creditsToShow !== undefined && creditsToShow !== null) {
709      const displayValue = creditsToShow;
710      const label = T('results.credits_spent') || 'Credits spent';
711      finalHtml += `<div class="credits-spent-info"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(String(displayValue))}</div>`;
712    }
713
714    const hasClaimGraph =
715      res?.deep_analysis?.claim_graph &&
716      Array.isArray(res.deep_analysis.claim_graph.nodes) &&
717      res.deep_analysis.claim_graph.nodes.length > 0;
718
719    finalHtml += `
720      <div class="share-actions">
721                <button id="copy-report-button" class="button secondary small">${T('button.copy_report')}</button>
722                <button id="share-button" class="button secondary small">${T('button.share')}</button>
723                <button id="share-image-button" class="button secondary small">${T('button.share_image')}</button>${hasClaimGraph ? `<button type="button" id="tree-link-button" class="button secondary small">${escapeHtml(T('share.view_3d_tree_short') || '3D tree')}</button>` : ''}
724            </div>`;
725
726    // 3. Expectation notice (moved after share buttons)
727    const expectationNotice = T('results.expectation_notice');
728    if (expectationNotice) {
729      finalHtml += `<div class="expectation-notice"><svg class="icon" aria-hidden="true"><use href="#info"></use></svg>${escapeHtml(expectationNotice)}</div>`;
730    }
731
732    // M41: Add "Powered by Spectrue" badge for free users
733    if (res?.meta?.powered_by_spectrue) {
734      // Use pricing page as fallback - it's public and doesn't require auth
735      const link =
736        res.meta.attribution_link || 'https://spectrue.net/pricing.html';
737      finalHtml += `
738  <div class="spectrue-badge">
739    <a href="${link}" target="_blank" rel="noopener noreferrer">Powered by Spectrue</a>
740            </div>`;
741    }
742
743    box.innerHTML = finalHtml;
744  }
745
746  // Attach click listeners to sentences (CSP safe)
747  box.querySelectorAll('.sentence').forEach((el) => {
748    el.addEventListener('click', () => {
749      const idx = parseInt(el.getAttribute('data-idx'), 10);
750      togglePopup(el, idx);
751    });
752  });
753
754  const copyButton = $('copy-report-button');
755  if (copyButton) {
756    copyButton.addEventListener('click', async () => {
757      const { copyTextReport } = await loadShareActions();
758      await copyTextReport(cachedResultPayload, { button: copyButton });
759      logEventSafe('result_share', {
760        channel: 'copy',
761        mode: isDeep ? 'deep' : 'standard',
762      });
763    });
764  }
765
766  const shareButton = $('share-button');
767  if (shareButton) {
768    shareButton.addEventListener('click', async () => {
769      const { shareReport } = await loadShareActions();
770      await shareReport(cachedResultPayload, { button: shareButton });
771      logEventSafe('result_share', {
772        channel: 'other',
773        mode: isDeep ? 'deep' : 'standard',
774      });
775    });
776  }
777
778  const shareImageButton = $('share-image-button');
779  if (shareImageButton) {
780    shareImageButton.addEventListener('click', async () => {
781      const { shareOgImage } = await loadShareActions();
782      await shareOgImage(cachedResultPayload, { button: shareImageButton });
783      logEventSafe('result_share', {
784        channel: 'image',
785        mode: isDeep ? 'deep' : 'standard',
786      });
787    });
788  }
789
790  const treeLinkButton = $('tree-link-button');
791  if (treeLinkButton && cachedResultPayload) {
792    treeLinkButton.addEventListener('click', async () => {
793      const checkId = cachedResultPayload.id || cachedResultPayload.result?.id;
794      // Prefer opening by report id: create share link then go to /tree/{shareId}
795      try {
796        if (checkId) {
797          const shareRes = await createShareLink(checkId);
798          if (shareRes?.ok && shareRes?.data?.share_id) {
799            window.location.href = `/tree/${shareRes.data.share_id}`;
800            return;
801          }
802        }
803      } catch (err) {
804        console.warn('Share link for tree failed', err);
805      }
806      // Fallback: pass data via sessionStorage and open /tree?check_id=...
807      const treeUrl = checkId ? `/tree?check_id=${encodeURIComponent(checkId)}` : '/tree';
808      try {
809        const result = cachedResultPayload.result ?? cachedResultPayload;
810        const payload = { analysis_result: result, source_info: {} };
811        if (checkId) payload.check_id = checkId;
812        sessionStorage.setItem('spectrue_tree_data', JSON.stringify(payload));
813      } catch (err) {
814        console.warn('Tree data save failed', err);
815      }
816      window.location.href = treeUrl;
817    });
818  }
819
820  const retrySearchButton = $('retry-search-button');
821  if (retrySearchButton) {
822    retrySearchButton.addEventListener('click', async () => {
823      const provider =
824        retrySearchButton.getAttribute('data-provider') || 'auto';
825      retrySearchButton.disabled = true;
826      try {
827        const { handleStartCheck } = await import('./handlers.js?v=M42h_fix17');
828        await handleStartCheck({ search_provider: provider });
829      } finally {
830        retrySearchButton.disabled = false;
831      }
832    });
833  }
834
835  // Analytics: track source link clicks via delegation
836  box.addEventListener('click', (e) => {
837    const link = e.target.closest('a');
838    if (link && (link.closest('.popup') || link.closest('.claim-card'))) {
839      let kind = 'other';
840      const hostname = (link.hostname || '').toLowerCase();
841      if (hostname.includes('wikipedia.org')) kind = 'wikipedia';
842      else if (hostname.includes('.gov')) kind = 'gov';
843      else if (
844        [
845          'facebook.com',
846          'x.com',
847          'twitter.com',
848          'instagram.com',
849          'youtube.com',
850          'tiktok.com',
851          'telegram.org',
852          'reddit.com',
853        ].some((s) => hostname.includes(s))
854      )
855        kind = 'social';
856      else if (
857        [
858          'news.',
859          'reuters.com',
860          'apnews.com',
861          'bbc.com',
862          'cnn.com',
863          'nytimes.com',
864          'guardian.com',
865        ].some((s) => hostname.includes(s))
866      )
867        kind = 'news';
868
869      const res = cachedResultPayload?.result;
870      const isDeep = !!(
871        res?.deep_analysis ||
872        res?.mode === 'deep' ||
873        res?.analysis_mode === 'deep'
874      );
875
876      logEventSafe('result_open_evidence', {
877        source_kind: kind,
878        mode: isDeep ? 'deep' : 'standard',
879      });
880    }
881  });
882
883  // Auto-open first sentence details
884
885  const firstSentence = box.querySelector('.sentence');
886  if (firstSentence) {
887    firstSentence.click();
888  }
889
890  // Analytics: page view for result
891  try {
892    logEventSafe('page_view_custom', { page_id: 'result' });
893  } catch (e) { }
894
895  return;
896}
897
898function renderLanguageSelector() {
899  const container = document.getElementById('header-lang-container');
900  if (!container) return;
901
902  const currentLang = getCurrentLocale();
903  const options = [
904    { value: 'en', label: '🇬🇧 English' },
905    { value: 'uk', label: '🇺🇦 Українська' },
906    { value: 'de', label: '🇩🇪 Deutsch' },
907    { value: 'es', label: '🇪🇸 Español' },
908    { value: 'fr', label: '🇫🇷 Français' },
909    { value: 'ja', label: '🇯🇵 日本語' },
910    { value: 'zh', label: '🇨🇳 中文' },
911    { value: 'ru', label: '🇷🇺 Русский' },
912  ];
913
914  container.innerHTML = `
915    <div class="header-lang-selector">
916      <select id="header-lang-select" class="header-lang-select">
917        ${options.map(opt => `
918          <option value="${opt.value}" ${currentLang === opt.value ? 'selected' : ''}>
919            ${opt.label}
920          </option>
921        `).join('')}
922      </select>
923    </div>
924  `;
925}
926
927function renderUserMenu(user) {
928  const container = document.getElementById('user-menu-container');
929  if (!container) return;
930
931  const sidebarBalance = document.getElementById('sidebar-balance-display');
932
933  if (!user) {
934    container.innerHTML = '';
935    const btn = document.createElement('button');
936    btn.id = 'login-button';
937    btn.className = 'button small primary';
938    btn.textContent = T('sidebar.login') || 'Login';
939    btn.onclick = () => {
940      // Analytics: auth start
941      logEventSafe('auth_start', { method: 'google' });
942
943      btn.innerHTML = '<span class="spinner-small"></span>'; // Show loading state
944      setTimeout(() => {
945        const nextUrl = encodeURIComponent(window.location.href);
946        const authParams = new URLSearchParams();
947        authParams.set('next', nextUrl);
948        if (isExtensionContext()) {
949          authParams.set('platform', 'extension');
950          authParams.set('lang', getCurrentLocale());
951        }
952        try {
953          window.location.href = `auth.html?${authParams.toString()}`;
954        } catch (e) {
955          // Fallback
956          window.open(`auth.html?${authParams.toString()}`, '_blank');
957        }
958      }, 50);
959    };
960    container.appendChild(btn);
961
962    // Hide balance for guests
963    if (sidebarBalance) sidebarBalance.style.display = 'none';
964    return;
965  }
966
967  // Show balance for logged-in users
968  if (sidebarBalance) sidebarBalance.style.display = 'block';
969
970  const email = user.email || 'User';
971  const photoURL =
972    user.photoURL ||
973    `https://ui-avatars.com/api/?name=${encodeURIComponent(email)}&background=random`;
974  const credits = user.credits !== undefined ? user.credits : '...';
975  const plan = user.plan || 'free';
976
977  // Check current theme/lang to set active states if needed (though simple toggle is enough)
978  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
979  const currentLang = getCurrentLocale();
980
981  // Populate Header Badges
982  const getPricingUrl = () => {
983    const params = new URLSearchParams(window.location.search);
984    const platform = params.get('platform');
985    return platform ? `pricing.html?platform=${platform}` : 'pricing.html';
986  };
987
988  const planBadge = document.getElementById('header-plan-badge');
989  if (planBadge) {
990    planBadge.textContent = plan;
991    planBadge.className = `header-badge badge-${plan}`;
992    planBadge.classList.remove('hidden');
993    planBadge.onclick = () => (window.location.href = getPricingUrl());
994  }
995
996  const creditsBadge = document.getElementById('header-credits-badge');
997  if (creditsBadge) {
998    const totalCredits = credits;
999    creditsBadge.textContent = `${totalCredits} cr`;
1000    creditsBadge.classList.remove('hidden');
1001    creditsBadge.onclick = () => (window.location.href = getPricingUrl());
1002
1003
1004    // Hover logic for breakdown
1005    const breakdown = user.credits_breakdown || {};
1006    const paid = parseFloat(
1007      breakdown.paid_credits_sc ||
1008      user.paid_credits_sc ||
1009      user.balance_sc ||
1010      '0'
1011    );
1012    const free = parseFloat(
1013      breakdown.free_credits_sc ||
1014      user.free_credits_sc ||
1015      user.available_sc ||
1016      '0'
1017    );
1018
1019    // Only add hover if we have a valid breakdown that sums conceptually to something relevant
1020    if (paid >= 0 || free >= 0) {
1021      const originalText = `${totalCredits} cr`;
1022
1023      // Allow some tolerance for float precision
1024      const paidInt = Math.floor(paid);
1025      const freeInt = Math.floor(free);
1026
1027      creditsBadge.onmouseenter = () => {
1028        // paid (green/gold) + free (cyan/blue) + info icon
1029        creditsBadge.innerHTML = `
1030          <div style="display: flex; align-items: center; gap: 4px;">
1031            <span style="color: var(--color-success); font-weight: bold;">${paidInt}</span>
1032            <span style="opacity: 0.5;">+</span>
1033            <span style="color: var(--color-primary); font-weight: bold;">${freeInt}</span>
1034            <span id="credits-info-icon" class="credits-info-icon">i</span>
1035          </div>
1036        `;
1037
1038        // Attach listener to icon once element is in DOM
1039        const icon = document.getElementById('credits-info-icon');
1040        if (icon) {
1041          icon.onclick = (e) => {
1042            e.stopPropagation(); // prevent navigation to pricing
1043            e.preventDefault();
1044
1045            const title = T('credits.breakdown_title');
1046            const content = `
1047              <div style="margin-bottom: 12px;">
1048                <strong style="color: var(--color-success);">${T('credits.paid_label')}</strong>
1049                <p style="margin: 4px 0 0 0; opacity: 0.8; font-size: 0.9em;">${T('credits.paid_desc')}</p>
1050              </div>
1051              <div>
1052                <strong style="color: var(--color-primary);">${T('credits.free_label')}</strong>
1053                <p style="margin: 4px 0 0 0; opacity: 0.8; font-size: 0.9em;">${T('credits.free_desc')}</p>
1054              </div>
1055            `;
1056            showModal(title, content);
1057          };
1058        }
1059      };
1060      creditsBadge.onmouseleave = () => {
1061        creditsBadge.textContent = originalText;
1062      };
1063    } else {
1064      creditsBadge.onmouseenter = null;
1065      creditsBadge.onmouseleave = null;
1066    }
1067
1068  }
1069
1070  // Populate Sidebar Balance
1071  if (sidebarBalance) {
1072    // Use T() if available for "Balance", otherwise fallback
1073    const label = T('credits.balance') || 'Balance';
1074    sidebarBalance.innerHTML = `<div>${label}: ${credits}</div>`;
1075
1076    // Wallet Details Stats (Debug only for localhost)
1077    const isLocal = ['localhost', '127.0.0.1'].includes(
1078      window.location.hostname
1079    );
1080
1081    if (isLocal && (user.wallet || user.pool)) {
1082      let detailsHtml = `<div style="margin-top:8px; padding-top:8px; border-top:1px dashed var(--border-default); font-size: 0.8em; opacity: 0.8;">`;
1083
1084      if (user.wallet) {
1085        const w = user.wallet;
1086        // Check API response structure or fallback to top-level aliases
1087        const paid = user.paid_credits_sc || w.balance_sc || '0';
1088        const free = user.free_credits_sc || w.available_sc || '0';
1089
1090        detailsHtml += `<div><strong>Wallet:</strong></div>`;
1091        detailsHtml += `<div style="color: var(--color-success);">Paid: ${paid}</div>`;
1092        detailsHtml += `<div style="color: var(--color-primary);">Free: ${free}</div>`;
1093
1094        // Show bonus info if relevant
1095        if (w.daily_bonus)
1096          detailsHtml += `<div>Daily: ${w.daily_bonus.claimed ? 'Claimed' : 'Avail'}</div>`;
1097      }
1098
1099      if (user.pool) {
1100        detailsHtml += `<div style="margin-top:4px;"><strong>Free Pool:</strong></div>`;
1101        detailsHtml += `<div>Available: ${user.pool.available_balance_sc || '0'}</div>`;
1102        const locked =
1103          user.pool.locked_total_sc ||
1104          (user.pool.locked_buckets || []).reduce(
1105            (acc, b) => acc + Number(b.amount_sc),
1106            0
1107          ) ||
1108          '0';
1109        detailsHtml += `<div>Locked: ${locked}</div>`;
1110      }
1111
1112      detailsHtml += `</div>`;
1113      sidebarBalance.insertAdjacentHTML('beforeend', detailsHtml);
1114    }
1115  }
1116
1117  // Populate User ID section
1118  const userIdSection = document.getElementById('user-id-section');
1119  const userIdValue = document.getElementById('user-id-value');
1120  const copyUserIdBtn = document.getElementById('copy-user-id-btn');
1121
1122  // Get UID from API response or directly from Firebase Auth
1123  const userUid = user.uid || window.SPECTRUE?.firebaseAuth?.currentUser?.uid;
1124
1125  if (userIdSection && userIdValue && userUid) {
1126    userIdValue.textContent = userUid;
1127    userIdValue.title = userUid; // Full ID on hover
1128    userIdSection.style.display = 'block';
1129
1130    // Setup copy handler (remove old listener first to avoid duplicates)
1131    if (copyUserIdBtn) {
1132      const newBtn = copyUserIdBtn.cloneNode(true);
1133      copyUserIdBtn.parentNode.replaceChild(newBtn, copyUserIdBtn);
1134      newBtn.addEventListener('click', async () => {
1135        try {
1136          await navigator.clipboard.writeText(userUid);
1137          showToast(T('toast.id_copied') || 'ID copied');
1138        } catch (e) {
1139          console.warn('Copy failed:', e);
1140        }
1141      });
1142    }
1143  } else if (userIdSection) {
1144    userIdSection.style.display = 'none';
1145  }
1146
1147  const html = `
1148    <button id="user-menu-trigger" class="user-menu-trigger icon-button" aria-label="Menu" aria-expanded="false" aria-haspopup="true">
1149      <svg class="icon"><use href="#menu"></use></svg>
1150    </button>
1151    <div id="user-menu-dropdown" class="user-menu-dropdown hidden">
1152      <div class="menu-actions">
1153        <a href="/profile.html" class="menu-item">
1154           <img class="menu-avatar" src="${photoURL}" alt="${escapeHtml(email)}" /> ${T('nav.profile') || 'Profile'}
1155        </a>
1156        <a href="${getPricingUrl()}" class="menu-item">
1157           <svg class="icon"><use href="#plus"></use></svg> ${T('credits.top_up') || 'Top Up'}
1158        </a>
1159        <button id="menu-history" class="menu-item">
1160           <svg class="icon"><use href="#clock"></use></svg> ${T('sidebar.history_title') || 'History'}
1161        </button>
1162        <a href="/api.html" class="menu-item">
1163           <svg class="icon"><use href="#settings"></use></svg> API Dashboard
1164        </a>
1165      </div>
1166      <div class="menu-settings">
1167        <button id="menu-theme-toggle" class="menu-item">
1168           <svg class="icon"><use href="#${isDark ? 'sun' : 'moon'}"></use></svg> 
1169           <span id="theme-text">${isDark ? T('theme.light') || 'Light Mode' : T('theme.dark') || 'Dark Mode'}</span>
1170        </button>
1171      </div>
1172      <div class="menu-footer">
1173        <button id="menu-logout" class="menu-item danger">
1174           <svg class="icon"><use href="#logout"></use></svg> ${T('sidebar.logout_text') || 'Log Out'}
1175        </button>
1176      </div>
1177    </div>
1178  `;
1179
1180  container.innerHTML = html;
1181
1182  // Bind events immediately after rendering
1183  const trigger = document.getElementById('user-menu-trigger');
1184  if (trigger) {
1185    trigger.onclick = (e) => {
1186      e.stopPropagation();
1187      toggleUserMenu();
1188    };
1189  }
1190
1191  // M140: Presentation Mode Selection in Menu
1192  const settingsContainer = container.querySelector('.menu-settings');
1193  if (settingsContainer) {
1194    const currentMode = reportPresentationModeContext.getMode();
1195
1196    const modeToggleContainer = document.createElement('div');
1197    modeToggleContainer.className = 'menu-item';
1198    modeToggleContainer.style.flexDirection = 'column';
1199    modeToggleContainer.style.alignItems = 'stretch';
1200    modeToggleContainer.style.cursor = 'default';
1201    modeToggleContainer.style.background = 'transparent';
1202    modeToggleContainer.style.padding = '8px';
1203    modeToggleContainer.style.gap = '8px';
1204
1205    const label = document.createElement('div');
1206    label.style.display = 'flex';
1207    label.style.alignItems = 'center';
1208    label.style.gap = '10px';
1209    label.innerHTML = `<svg class="icon"><use href="#eye"></use></svg> <span>${escapeHtml(T('menu.presentation_mode') || 'View Mode')}</span>`;
1210
1211    const toggleGroup = document.createElement('div');
1212    toggleGroup.className = 'presentation-mode-toggle';
1213    toggleGroup.style.display = 'flex';
1214    toggleGroup.style.width = '100%';
1215    // Use slightly smaller font/padding for menu context if needed, but class should handle it
1216
1217    const defaultBtn = document.createElement('button');
1218    defaultBtn.type = 'button';
1219    defaultBtn.textContent = T('report.mode_default') || 'Default';
1220    defaultBtn.style.flex = '1';
1221    defaultBtn.style.textAlign = 'center';
1222    defaultBtn.style.justifyContent = 'center';
1223
1224    if (currentMode !== 'expert') defaultBtn.className = 'is-active';
1225    defaultBtn.onclick = async (e) => {
1226      e.stopPropagation();
1227      reportPresentationModeContext.setMode('default');
1228      const { saveUserSettings } = await import('./api.js?v=M42h_fix17');
1229      saveUserSettings({ presentation_mode: 'default' });
1230    };
1231
1232    const expertBtn = document.createElement('button');
1233    expertBtn.type = 'button';
1234    expertBtn.textContent = T('report.mode_expert') || 'Expert';
1235    expertBtn.style.flex = '1';
1236    expertBtn.style.textAlign = 'center';
1237    expertBtn.style.justifyContent = 'center';
1238
1239    if (currentMode === 'expert') expertBtn.className = 'is-active';
1240    expertBtn.onclick = async (e) => {
1241      e.stopPropagation();
1242      reportPresentationModeContext.setMode('expert');
1243      const { saveUserSettings } = await import('./api.js?v=M42h_fix17');
1244      saveUserSettings({ presentation_mode: 'expert' });
1245    };
1246    toggleGroup.appendChild(defaultBtn);
1247    toggleGroup.appendChild(expertBtn);
1248
1249    modeToggleContainer.appendChild(label);
1250    modeToggleContainer.appendChild(toggleGroup);
1251    settingsContainer.appendChild(modeToggleContainer);
1252  }
1253
1254  window.dispatchEvent(new Event('spectrue:header-updated'));
1255}
1256
1257function toggleUserMenu() {
1258  const dropdown = document.getElementById('user-menu-dropdown');
1259  const trigger = document.getElementById('user-menu-trigger');
1260  if (!dropdown || !trigger) return;
1261
1262  const isHidden = dropdown.classList.contains('hidden');
1263  if (isHidden) {
1264    dropdown.classList.remove('hidden');
1265    trigger.setAttribute('aria-expanded', 'true');
1266    // Close on click outside
1267    document.addEventListener('click', closeUserMenuOnClickOutside);
1268  } else {
1269    dropdown.classList.add('hidden');
1270    trigger.setAttribute('aria-expanded', 'false');
1271    document.removeEventListener('click', closeUserMenuOnClickOutside);
1272  }
1273}
1274
1275function toggleHistoryModal() {
1276  let modal = document.getElementById('history-modal');
1277  if (!modal) {
1278    modal = document.createElement('div');
1279    modal.id = 'history-modal';
1280    modal.className = 'modal-overlay';
1281    modal.innerHTML = `
1282      <div class="modal-content">
1283        <div class="modal-header">
1284          <h3>${T('sidebar.history_title') || 'History'}</h3>
1285          <button class="icon-button close-modal"><svg class="icon"><use href="#plus" style="transform:rotate(45deg)"></use></svg></button>
1286        </div>
1287        <div id="history-list-container" class="modal-body">
1288           <!-- History items injected here -->
1289        </div>
1290      </div>
1291    `;
1292    document.body.appendChild(modal);
1293
1294    modal.querySelector('.close-modal').onclick = () =>
1295      (modal.style.display = 'none');
1296    modal.onclick = (e) => {
1297      if (e.target === modal) modal.style.display = 'none';
1298    };
1299  }
1300
1301  modal.style.display = 'flex';
1302
1303  // Trigger load history logic (imported from handlers in app.js usually, but we can dispatch event or expect caller to handle)
1304  // Better to dispatch an event that app.js or handlers.js listens to
1305  window.dispatchEvent(new CustomEvent('spectrue:load-history'));
1306}
1307
1308function initDeepToggleListener() {
1309  if (window._deepToggleInitializedFlag) return;
1310  window._deepToggleInitializedFlag = true;
1311
1312  const btn = document.getElementById('sidebar-deep-toggle');
1313  const checkbox = document.getElementById('deep-analysis-toggle');
1314  const cardHeader = document.querySelector('.sidebar-card .card-header');
1315  const cardIcon = document.querySelector('.sidebar-card .card-icon');
1316
1317  if (!checkbox) return;
1318
1319  const updateState = () => {
1320    const isActive = checkbox.checked;
1321    if (btn) {
1322      if (isActive) btn.classList.add('active');
1323      else btn.classList.remove('active');
1324    }
1325    if (cardIcon) {
1326      if (isActive) cardIcon.classList.add('active');
1327      else cardIcon.classList.remove('active');
1328    }
1329  };
1330
1331  // Sync initial state
1332  updateState();
1333
1334  const toggle = (e) => {
1335    e.preventDefault();
1336    e.stopPropagation();
1337    e.stopImmediatePropagation();
1338
1339    checkbox.checked = !checkbox.checked;
1340    updateState();
1341    checkbox.dispatchEvent(new Event('change'));
1342  };
1343  if (btn) btn.onclick = toggle;
1344  if (cardHeader) cardHeader.onclick = toggle;
1345
1346  // Listen for external changes to the checkbox to keep buttons in sync
1347  checkbox.addEventListener('change', updateState);
1348}
1349
1350function closeUserMenuOnClickOutside(event) {
1351  const dropdown = document.getElementById('user-menu-dropdown');
1352  const trigger = document.getElementById('user-menu-trigger');
1353  if (!dropdown || !trigger) return;
1354
1355  if (!dropdown.contains(event.target) && !trigger.contains(event.target)) {
1356    dropdown.classList.add('hidden');
1357    trigger.setAttribute('aria-expanded', 'false');
1358    document.removeEventListener('click', closeUserMenuOnClickOutside);
1359  }
1360}
1361
1362// --- Global Tooltip Logic ---
1363// Now imported from helpers/ui_components.js
1364
1365// M140: Subscribe to presentation mode changes to re-render UI if needed
1366reportPresentationModeContext.subscribe(() => {
1367  // Re-render menu to update active state
1368  const user = window.SPECTRUE?.firebaseAuth?.currentUser;
1369  if (user) {
1370    import('./api.js?v=M42h_fix17').then(({ meCredits }) => {
1371      meCredits().then((r) => {
1372        if (r.ok) renderUserMenu(r.data);
1373      });
1374    });
1375  } else {
1376    renderUserMenu(null);
1377  }
1378
1379  // Re-render result box
1380  if (cachedResultPayload) {
1381    if (cachedResultPayload.status === 'done' && cachedResultPayload.result) {
1382      renderCheckResult(cachedResultPayload);
1383    }
1384  }
1385});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.