viberadar 0.3.81 → 0.3.82

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.
@@ -389,6 +389,61 @@
389
389
  .feature-progress-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
390
390
  .feature-progress-label { font-size: 11px; color: var(--muted); white-space: nowrap; }
391
391
 
392
+ /* ── Documentation screen ────────────────────────────────────────────────── */
393
+ .doc-kpi-strip {
394
+ display: flex; gap: 24px; padding: 12px 16px; margin-bottom: 16px;
395
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
396
+ }
397
+ .doc-kpi { display: flex; flex-direction: column; align-items: center; gap: 2px; }
398
+ .doc-kpi-val { font-size: 20px; font-weight: 700; color: var(--text); }
399
+ .doc-kpi-lbl { font-size: 11px; color: var(--muted); }
400
+ .doc-feature-card { cursor: pointer; }
401
+ .doc-agent-btn {
402
+ font-size: 11px; padding: 4px 10px; border-radius: 6px;
403
+ border: 1px solid var(--border); background: var(--bg-hover); color: var(--text);
404
+ cursor: pointer; transition: background 0.15s;
405
+ }
406
+ .doc-agent-btn:hover { background: var(--blue); color: #fff; border-color: var(--blue); }
407
+ .doc-changed-section {
408
+ margin-bottom: 16px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
409
+ }
410
+ .doc-changed-header {
411
+ padding: 8px 12px; background: var(--bg-card); color: var(--text);
412
+ font-size: 13px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 8px;
413
+ }
414
+ .doc-changed-arrow { font-size: 10px; transition: transform 0.2s; }
415
+ .doc-changed-section.open .doc-changed-arrow { transform: rotate(90deg); }
416
+ .doc-changed-list { display: none; padding: 8px 12px; }
417
+ .doc-changed-section.open .doc-changed-list { display: block; }
418
+ .doc-changed-file {
419
+ font-size: 12px; color: var(--yellow); padding: 3px 0;
420
+ font-family: 'SF Mono', 'Fira Code', monospace;
421
+ }
422
+ .doc-content {
423
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
424
+ padding: 20px 24px; line-height: 1.65; color: var(--text);
425
+ }
426
+ .doc-content .doc-h { margin: 20px 0 8px 0; color: var(--text); font-weight: 600; }
427
+ .doc-content h1.doc-h { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
428
+ .doc-content h2.doc-h { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
429
+ .doc-content h3.doc-h { font-size: 15px; }
430
+ .doc-content .doc-p { margin: 8px 0; font-size: 14px; }
431
+ .doc-content .doc-list { margin: 8px 0; padding-left: 24px; font-size: 14px; }
432
+ .doc-content .doc-list li { margin: 4px 0; }
433
+ .doc-content .doc-code {
434
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
435
+ padding: 12px 16px; overflow-x: auto; font-size: 13px; margin: 12px 0;
436
+ }
437
+ .doc-content .doc-inline-code {
438
+ background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
439
+ padding: 1px 5px; font-size: 12px;
440
+ }
441
+ .back-btn {
442
+ background: none; border: 1px solid var(--border); color: var(--muted);
443
+ padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
444
+ }
445
+ .back-btn:hover { color: var(--text); border-color: var(--dim); }
446
+
392
447
  /* ── No config banner ────────────────────────────────────────────────────── */
393
448
  .no-config {
394
449
  display: flex;
@@ -1363,15 +1418,19 @@ function switchObsTab(tabId) {
1363
1418
  const modeStore = {
1364
1419
  qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1365
1420
  observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1421
+ docs: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1366
1422
  };
1367
1423
 
1368
1424
  function getModeFromPath(pathname = window.location.pathname) {
1369
1425
  if (pathname.startsWith('/radar/observability')) return 'observability';
1426
+ if (pathname.startsWith('/radar/docs')) return 'docs';
1370
1427
  return 'qa';
1371
1428
  }
1372
1429
 
1373
1430
  function routePathForMode(mode) {
1374
- return mode === 'observability' ? '/radar/observability' : '/radar/qa';
1431
+ if (mode === 'observability') return '/radar/observability';
1432
+ if (mode === 'docs') return '/radar/docs';
1433
+ return '/radar/qa';
1375
1434
  }
1376
1435
 
1377
1436
  function setModeRoute(mode, replace = false) {
@@ -1408,8 +1467,8 @@ function switchMode(nextMode) {
1408
1467
  saveModeState(contextMode);
1409
1468
  contextMode = nextMode;
1410
1469
  restoreModeState(contextMode);
1411
- if (contextMode === 'observability') {
1412
- view = 'files';
1470
+ if (contextMode === 'observability' || contextMode === 'docs') {
1471
+ view = 'features';
1413
1472
  drillFeatureKey = null;
1414
1473
  drillTestType = null;
1415
1474
  activePanelKey = null;
@@ -2618,6 +2677,7 @@ function renderModeSwitch() {
2618
2677
  const modes = [
2619
2678
  { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2620
2679
  { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2680
+ { key: 'docs', label: 'Документация', hint: 'Актуальность, генерация, обновление' },
2621
2681
  ];
2622
2682
  root.innerHTML = modes.map(m => `
2623
2683
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
@@ -2646,6 +2706,16 @@ function renderSidebar() {
2646
2706
  return;
2647
2707
  }
2648
2708
 
2709
+ if (contextMode === 'docs') {
2710
+ tabs.style.display = 'none';
2711
+ extra.innerHTML = `
2712
+ <div class="sidebar-label">Документация</div>
2713
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2714
+ Статус и актуальность документации по фичам.
2715
+ </div>`;
2716
+ return;
2717
+ }
2718
+
2649
2719
  tabs.style.display = 'flex';
2650
2720
  document.querySelectorAll('.view-tab').forEach(t =>
2651
2721
  t.classList.toggle('active', t.dataset.view === view)
@@ -2690,6 +2760,10 @@ function renderContent() {
2690
2760
  renderObservability(c);
2691
2761
  return;
2692
2762
  }
2763
+ if (contextMode === 'docs') {
2764
+ renderDocumentation(c);
2765
+ return;
2766
+ }
2693
2767
 
2694
2768
  if (view === 'features') {
2695
2769
  if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
@@ -3531,6 +3605,258 @@ function renderObservabilityOverview(c) {
3531
3605
  </div>`;
3532
3606
  }
3533
3607
 
3608
+ // ─── Documentation screen ─────────────────────────────────────────────────────
3609
+
3610
+ function renderDocumentation(c) {
3611
+ const doc = D.documentation;
3612
+ if (!doc) {
3613
+ c.innerHTML = `<div class="onboarding-block"><h3>Документация</h3><p>Данные документации недоступны. Убедитесь, что в проекте есть <code>viberadar.config.json</code> с фичами и пересканируйте проект.</p></div>`;
3614
+ return;
3615
+ }
3616
+ if (drillFeatureKey) renderDocFeatureDetail(c);
3617
+ else renderDocFeatureCards(c);
3618
+ }
3619
+
3620
+ function renderDocFeatureCards(c) {
3621
+ const doc = D.documentation;
3622
+ if (!doc || !doc.features.length) {
3623
+ c.innerHTML = `<div class="onboarding-block"><h3>Документация</h3><p>Фичи не найдены. Добавьте фичи в viberadar.config.json.</p></div>`;
3624
+ return;
3625
+ }
3626
+
3627
+ const q = searchQuery.toLowerCase();
3628
+ const filtered = doc.features.filter(f => !q || f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q));
3629
+
3630
+ const docPct = doc.totalFeatures > 0 ? Math.round(((doc.freshCount + doc.staleCount) / doc.totalFeatures) * 100) : 0;
3631
+
3632
+ let html = `
3633
+ <div class="doc-kpi-strip">
3634
+ <div class="doc-kpi"><span class="doc-kpi-val">${doc.totalFeatures}</span><span class="doc-kpi-lbl">Всего фич</span></div>
3635
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--green)">${doc.freshCount}</span><span class="doc-kpi-lbl">Актуальных</span></div>
3636
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--yellow)">${doc.staleCount}</span><span class="doc-kpi-lbl">Устаревших</span></div>
3637
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--red)">${doc.missingCount}</span><span class="doc-kpi-lbl">Без доки</span></div>
3638
+ <div class="doc-kpi"><span class="doc-kpi-val">${docPct}%</span><span class="doc-kpi-lbl">Задокументировано</span></div>
3639
+ </div>
3640
+ <div class="features-grid">`;
3641
+
3642
+ for (const f of filtered) {
3643
+ const statusColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3644
+ const statusText = !f.docExists ? 'Отсутствует' : f.isStale ? `Устарела (${f.changedFilesSinceDoc.length} файл.)` : 'Актуальна';
3645
+ const statusDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};margin-right:6px"></span>`;
3646
+ const lastUpd = f.lastUpdated ? new Date(f.lastUpdated).toLocaleDateString('ru-RU') : '—';
3647
+ const freshFiles = f.docExists ? (f.sourceFileCount - f.changedFilesSinceDoc.length) : 0;
3648
+ const progressPct = f.sourceFileCount > 0 ? Math.round((freshFiles / f.sourceFileCount) * 100) : 0;
3649
+ const progressColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3650
+
3651
+ const agentTask = !f.docExists ? 'generate-docs' : f.isStale ? 'update-docs' : null;
3652
+ const agentLabel = !f.docExists ? 'Написать доку' : f.isStale ? 'Обновить доку' : '';
3653
+ const agentBtn = agentTask
3654
+ ? `<button class="agent-card-btn doc-agent-btn" data-task="${agentTask}" data-key="${f.key}" style="margin-top:8px">${agentLabel}</button>`
3655
+ : `<div style="margin-top:8px;font-size:11px;color:var(--green)">Документация актуальна</div>`;
3656
+
3657
+ html += `
3658
+ <div class="feature-card doc-feature-card" data-key="${f.key}">
3659
+ <div class="feature-accent" style="background:${f.color}"></div>
3660
+ <div class="feature-body">
3661
+ <div class="feature-title">
3662
+ <span>${escapeHtml(f.label)}</span>
3663
+ <span class="feature-file-count">${f.sourceFileCount} файл.</span>
3664
+ </div>
3665
+ <div style="display:flex;align-items:center;gap:4px;margin:6px 0;font-size:12px">
3666
+ ${statusDot}<span style="color:${statusColor};font-weight:600">${statusText}</span>
3667
+ </div>
3668
+ <div style="font-size:11px;color:var(--muted);margin-bottom:6px">Обновлено: ${lastUpd}</div>
3669
+ <div class="feature-progress-bar">
3670
+ <div class="feature-progress-fill" style="width:${f.docExists ? progressPct : 0}%;background:${progressColor}"></div>
3671
+ </div>
3672
+ <div class="feature-progress-label">${f.docExists ? (f.isStale ? `${f.changedFilesSinceDoc.length} из ${f.sourceFileCount} изменены` : `${f.sourceFileCount} из ${f.sourceFileCount} актуальны`) : 'Документация отсутствует'}</div>
3673
+ ${agentBtn}
3674
+ </div>
3675
+ </div>`;
3676
+ }
3677
+ html += '</div>';
3678
+ c.innerHTML = html;
3679
+
3680
+ // Card click → drill-down
3681
+ c.querySelectorAll('.doc-feature-card').forEach(card => {
3682
+ card.addEventListener('click', (e) => {
3683
+ if (e.target.closest('.doc-agent-btn')) return;
3684
+ setFeatureDrill(card.dataset.key);
3685
+ });
3686
+ });
3687
+
3688
+ // Agent button clicks
3689
+ c.querySelectorAll('.doc-agent-btn').forEach(btn => {
3690
+ btn.addEventListener('click', (e) => {
3691
+ e.stopPropagation();
3692
+ runAgentTask(btn.dataset.task, btn.dataset.key);
3693
+ });
3694
+ });
3695
+ }
3696
+
3697
+ // Lightweight markdown → HTML renderer
3698
+ function renderMarkdownToHtml(md) {
3699
+ let html = '';
3700
+ const lines = md.split('\n');
3701
+ let inCodeBlock = false;
3702
+ let codeBlockContent = '';
3703
+ let inList = false;
3704
+ let listType = '';
3705
+
3706
+ for (let i = 0; i < lines.length; i++) {
3707
+ const line = lines[i];
3708
+
3709
+ // Code blocks
3710
+ if (line.trimStart().startsWith('```')) {
3711
+ if (inCodeBlock) {
3712
+ html += `<pre class="doc-code"><code>${escapeHtml(codeBlockContent.trimEnd())}</code></pre>`;
3713
+ codeBlockContent = '';
3714
+ inCodeBlock = false;
3715
+ } else {
3716
+ if (inList) { html += listType === 'ul' ? '</ul>' : '</ol>'; inList = false; }
3717
+ inCodeBlock = true;
3718
+ }
3719
+ continue;
3720
+ }
3721
+ if (inCodeBlock) {
3722
+ codeBlockContent += line + '\n';
3723
+ continue;
3724
+ }
3725
+
3726
+ // Headings
3727
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
3728
+ if (headingMatch) {
3729
+ if (inList) { html += listType === 'ul' ? '</ul>' : '</ol>'; inList = false; }
3730
+ const level = headingMatch[1].length;
3731
+ html += `<h${level} class="doc-h">${inlineFormat(headingMatch[2])}</h${level}>`;
3732
+ continue;
3733
+ }
3734
+
3735
+ // Unordered list
3736
+ if (/^\s*[-*]\s+/.test(line)) {
3737
+ if (!inList || listType !== 'ul') {
3738
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3739
+ html += '<ul class="doc-list">';
3740
+ inList = true;
3741
+ listType = 'ul';
3742
+ }
3743
+ html += `<li>${inlineFormat(line.replace(/^\s*[-*]\s+/, ''))}</li>`;
3744
+ continue;
3745
+ }
3746
+
3747
+ // Ordered list
3748
+ if (/^\s*\d+\.\s+/.test(line)) {
3749
+ if (!inList || listType !== 'ol') {
3750
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3751
+ html += '<ol class="doc-list">';
3752
+ inList = true;
3753
+ listType = 'ol';
3754
+ }
3755
+ html += `<li>${inlineFormat(line.replace(/^\s*\d+\.\s+/, ''))}</li>`;
3756
+ continue;
3757
+ }
3758
+
3759
+ // End list
3760
+ if (inList && line.trim() === '') {
3761
+ html += listType === 'ul' ? '</ul>' : '</ol>';
3762
+ inList = false;
3763
+ }
3764
+
3765
+ // Empty line
3766
+ if (line.trim() === '') continue;
3767
+
3768
+ // Paragraph
3769
+ if (!inList) {
3770
+ html += `<p class="doc-p">${inlineFormat(line)}</p>`;
3771
+ }
3772
+ }
3773
+
3774
+ if (inCodeBlock) {
3775
+ html += `<pre class="doc-code"><code>${escapeHtml(codeBlockContent.trimEnd())}</code></pre>`;
3776
+ }
3777
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3778
+ return html;
3779
+ }
3780
+
3781
+ function inlineFormat(text) {
3782
+ let s = escapeHtml(text);
3783
+ s = s.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
3784
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
3785
+ s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
3786
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--blue)">$1</a>');
3787
+ return s;
3788
+ }
3789
+
3790
+ let docContentCache = {};
3791
+
3792
+ async function renderDocFeatureDetail(c) {
3793
+ const doc = D.documentation;
3794
+ const f = doc?.features.find(x => x.key === drillFeatureKey);
3795
+ if (!f) {
3796
+ c.innerHTML = `<div class="onboarding-block"><p>Фича не найдена.</p></div>`;
3797
+ return;
3798
+ }
3799
+
3800
+ const statusColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3801
+ const statusText = !f.docExists ? 'Отсутствует' : f.isStale ? `Устарела (${f.changedFilesSinceDoc.length} файл.)` : 'Актуальна';
3802
+ const statusDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};margin-right:6px"></span>`;
3803
+ const lastUpd = f.lastUpdated ? new Date(f.lastUpdated).toLocaleDateString('ru-RU') : '—';
3804
+
3805
+ const agentTask = !f.docExists ? 'generate-docs' : f.isStale ? 'update-docs' : null;
3806
+ const agentLabel = !f.docExists ? 'Сгенерировать документацию' : f.isStale ? `Обновить документацию (${f.changedFilesSinceDoc.length} файл.)` : '';
3807
+
3808
+ let changedFilesHtml = '';
3809
+ if (f.isStale && f.changedFilesSinceDoc.length > 0) {
3810
+ changedFilesHtml = `
3811
+ <div class="doc-changed-section">
3812
+ <div class="doc-changed-header" onclick="this.parentElement.classList.toggle('open')">
3813
+ Изменённые файлы (${f.changedFilesSinceDoc.length})
3814
+ <span class="doc-changed-arrow">&#9654;</span>
3815
+ </div>
3816
+ <div class="doc-changed-list">
3817
+ ${f.changedFilesSinceDoc.map(fp => `<div class="doc-changed-file">${escapeHtml(fp)}</div>`).join('')}
3818
+ </div>
3819
+ </div>`;
3820
+ }
3821
+
3822
+ let docContentHtml = '';
3823
+ if (f.docExists) {
3824
+ // Fetch content if not cached
3825
+ if (!docContentCache[f.key]) {
3826
+ try {
3827
+ const resp = await fetch('/api/docs/content?feature=' + encodeURIComponent(f.key));
3828
+ const data = await resp.json();
3829
+ if (data.exists && data.content) docContentCache[f.key] = data.content;
3830
+ } catch {}
3831
+ }
3832
+ const raw = docContentCache[f.key] || '';
3833
+ docContentHtml = raw ? `<div class="doc-content">${renderMarkdownToHtml(raw)}</div>` : '<div style="color:var(--muted);padding:16px">Не удалось загрузить содержимое документации.</div>';
3834
+ } else {
3835
+ docContentHtml = `<div style="color:var(--muted);padding:16px;text-align:center">Документация ещё не создана. Нажмите кнопку ниже, чтобы сгенерировать.</div>`;
3836
+ }
3837
+
3838
+ c.innerHTML = `
3839
+ <div style="margin-bottom:16px">
3840
+ <button class="back-btn" id="docBackBtn">&#8592; Все фичи</button>
3841
+ </div>
3842
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
3843
+ <div style="width:4px;height:28px;border-radius:2px;background:${f.color}"></div>
3844
+ <h2 style="margin:0;font-size:18px;color:var(--text)">${escapeHtml(f.label)}</h2>
3845
+ <div style="display:flex;align-items:center;font-size:13px">${statusDot}<span style="color:${statusColor};font-weight:600">${statusText}</span></div>
3846
+ <span style="font-size:12px;color:var(--muted)">Обновлено: ${lastUpd}</span>
3847
+ </div>
3848
+ ${agentTask ? `<div style="margin-bottom:16px"><button class="agent-card-btn doc-detail-agent-btn" data-task="${agentTask}" data-key="${f.key}">${agentLabel}</button></div>` : ''}
3849
+ ${changedFilesHtml}
3850
+ ${docContentHtml}`;
3851
+
3852
+ document.getElementById('docBackBtn').onclick = () => backToFeatures();
3853
+
3854
+ const detailAgentBtn = c.querySelector('.doc-detail-agent-btn');
3855
+ if (detailAgentBtn) {
3856
+ detailAgentBtn.onclick = () => runAgentTask(detailAgentBtn.dataset.task, detailAgentBtn.dataset.key);
3857
+ }
3858
+ }
3859
+
3534
3860
  function renderFeatureCards(c) {
3535
3861
  if (!D.hasConfig || !D.features) {
3536
3862
  c.innerHTML = `
@@ -4637,6 +4963,7 @@ async function refreshData() {
4637
4963
  try {
4638
4964
  const res = await fetch('/api/data');
4639
4965
  D = await res.json();
4966
+ docContentCache = {};
4640
4967
 
4641
4968
  // Update header timestamp
4642
4969
  document.getElementById('scannedAt').textContent =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberadar",
3
- "version": "0.3.81",
3
+ "version": "0.3.82",
4
4
  "description": "Live module map with test coverage for vibecoding projects",
5
5
  "main": "./dist/cli.js",
6
6
  "bin": {