@xera-ai/core 0.11.2 → 0.11.3

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.
@@ -143,6 +143,16 @@
143
143
  var nodes = new vis.DataSet(nodeData);
144
144
  var edges = new vis.DataSet(edgeData);
145
145
 
146
+ // ── Pre-compute adjacency + cache arrays for O(1) lookups ───
147
+ var adjacency = Object.create(null);
148
+ for (const n of nodeData) adjacency[n.id] = new Set();
149
+ for (const er of edgeData) {
150
+ if (adjacency[er.from]) adjacency[er.from].add(er.to);
151
+ if (adjacency[er.to]) adjacency[er.to].add(er.from);
152
+ }
153
+ var allNodeIds = nodeData.map((n) => n.id);
154
+ var edgeIndex = edgeData.map((e) => ({ id: e.id, from: e.from, to: e.to, baseColor: e.color }));
155
+
146
156
  // ── Network init ─────────────────────────────────────
147
157
  var network = new vis.Network(
148
158
  container,
@@ -177,18 +187,51 @@
177
187
  },
178
188
  );
179
189
 
190
+ container.style.opacity = '0';
191
+
192
+ // ── Physics state machine (guarded to avoid redundant setOptions) ───
193
+ var physicsOn = true; // initially true during stabilization
194
+ function setPhysics(on) {
195
+ if (physicsOn === on) return;
196
+ physicsOn = on;
197
+ network.setOptions({ physics: { enabled: on } });
198
+ }
199
+
180
200
  // ── Progress bar ─────────────────────────────────────
181
201
  var progressBar = document.getElementById('progress-bar');
182
202
  network.on('stabilizationProgress', (p) => {
183
203
  progressBar.style.width = `${Math.round((p.iterations / p.total) * 100)}%`;
184
204
  });
185
205
  network.once('stabilizationIterationsDone', () => {
206
+ setPhysics(false);
207
+ network.fit();
208
+ container.style.transition = 'opacity 0.3s';
209
+ container.style.opacity = '1';
186
210
  progressBar.style.width = '100%';
187
211
  setTimeout(() => {
188
212
  progressBar.style.transition = 'opacity .4s';
189
213
  progressBar.style.opacity = '0';
190
214
  }, 300);
191
- network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
215
+ });
216
+
217
+ // ── Drag → temporarily enable physics so connected nodes react ───
218
+ var _disableTimer = null;
219
+ var _enableTimer = null;
220
+ network.on('dragStart', (params) => {
221
+ if (!params.nodes.length) return;
222
+ clearTimeout(_disableTimer);
223
+ clearTimeout(_enableTimer);
224
+ // Only enable on real drags (held > ~80ms) — clicks fire dragStart+dragEnd instantly
225
+ _enableTimer = setTimeout(() => {
226
+ setPhysics(true);
227
+ }, 80);
228
+ });
229
+ network.on('dragEnd', (params) => {
230
+ clearTimeout(_enableTimer);
231
+ if (!params.nodes.length) return;
232
+ _disableTimer = setTimeout(() => {
233
+ setPhysics(false);
234
+ }, 1200);
192
235
  });
193
236
 
194
237
  // ── Side panel ───────────────────────────────────────
@@ -239,82 +282,145 @@
239
282
  sidepanel.classList.remove('hidden');
240
283
  }
241
284
 
242
- function dimOthers(nodeId) {
243
- var hop1 = new Set(network.getConnectedNodes(nodeId));
244
- var hop2 = new Set();
245
- for (const id of hop1) {
246
- for (const x of network.getConnectedNodes(id)) {
247
- hop2.add(x);
248
- }
249
- }
250
- var keep = new Set([nodeId, ...hop1, ...hop2]);
251
- for (const n of nodes.get()) {
252
- nodes.update({ id: n.id, opacity: keep.has(n.id) ? 1 : 0.1 });
253
- }
254
- for (const e of edges.get()) {
255
- const visible = keep.has(e.from) && keep.has(e.to);
256
- edges.update({
285
+ // ── Highlight / dim state machine ────────────────────────────
286
+ // Uses pre-computed adjacency + cached id arrays for O(1) neighbor lookup
287
+ // and a single batched DataSet update per state change.
288
+ var dimmedFor = null; // currently dimmed-for node id, or null
289
+ var pendingDim; // undefined = no pending; null = clear; string = dim for id
290
+ var pendingRaf = 0;
291
+
292
+ function neighborSet(nodeId) {
293
+ var keep = new Set([nodeId]);
294
+ var hop1 = adjacency[nodeId];
295
+ if (!hop1) return keep;
296
+ hop1.forEach((x) => {
297
+ keep.add(x);
298
+ var a = adjacency[x];
299
+ if (a)
300
+ a.forEach((y) => {
301
+ keep.add(y);
302
+ });
303
+ });
304
+ return keep;
305
+ }
306
+
307
+ function applyDim(nodeId) {
308
+ if (dimmedFor === nodeId) return;
309
+ dimmedFor = nodeId;
310
+ var keep = neighborSet(nodeId);
311
+ nodes.update(allNodeIds.map((id) => ({ id: id, opacity: keep.has(id) ? 1 : 0.15 })));
312
+ edges.update(
313
+ edgeIndex.map((e) => ({
257
314
  id: e.id,
258
- color: Object.assign({}, e.color, { opacity: visible ? 0.8 : 0.06 }),
259
- });
260
- }
315
+ color: Object.assign({}, e.baseColor, {
316
+ opacity: keep.has(e.from) && keep.has(e.to) ? 0.8 : 0.04,
317
+ }),
318
+ })),
319
+ );
261
320
  }
262
321
 
263
- function resetView() {
264
- for (const n of nodes.get()) {
265
- nodes.update({ id: n.id, opacity: 1 });
266
- }
267
- for (const e of edges.get()) {
268
- edges.update({ id: e.id, color: Object.assign({}, e.color, { opacity: 0.8 }) });
269
- }
322
+ function clearDim() {
323
+ if (dimmedFor === null) return;
324
+ dimmedFor = null;
325
+ nodes.update(allNodeIds.map((id) => ({ id: id, opacity: 1 })));
326
+ edges.update(edgeIndex.map((e) => ({ id: e.id, color: e.baseColor })));
327
+ }
328
+
329
+ // Schedule dim work in next animation frame so the panel renders first.
330
+ // Debounced: if multiple state changes happen before the frame, only the
331
+ // latest wins (prevents flicker on rapid clicks).
332
+ function scheduleDim(nodeIdOrNull) {
333
+ pendingDim = nodeIdOrNull;
334
+ if (pendingRaf) return;
335
+ pendingRaf = requestAnimationFrame(() => {
336
+ pendingRaf = 0;
337
+ var target = pendingDim;
338
+ pendingDim = undefined;
339
+ if (target === null) clearDim();
340
+ else if (typeof target === 'string') applyDim(target);
341
+ });
342
+ }
343
+
344
+ function hidePanel() {
270
345
  sidepanel.classList.add('hidden');
271
346
  }
272
347
 
273
- network.on('click', (params) => {
274
- if (params.nodes.length === 0) {
275
- resetView();
276
- return;
277
- }
278
- showPanel(params.nodes[0]);
279
- dimOthers(params.nodes[0]);
348
+ // ── Selection events ─────────────────────────────────
349
+ // Use selectNode/deselectNode these only fire on actual selection changes,
350
+ // unlike `click` which also fires after pan/drag.
351
+ var _deselectTimer = null;
352
+
353
+ network.on('selectNode', (params) => {
354
+ clearTimeout(_deselectTimer);
355
+ var id = params.nodes[0];
356
+ showPanel(id); // synchronous, fast — panel appears immediately
357
+ scheduleDim(id); // heavy dim work deferred to next frame
280
358
  });
281
359
 
360
+ network.on('deselectNode', () => {
361
+ // Defer so that switching directly between nodes (deselect→select)
362
+ // doesn't flash the panel closed in between.
363
+ _deselectTimer = setTimeout(() => {
364
+ hidePanel();
365
+ scheduleDim(null);
366
+ }, 0);
367
+ });
368
+
369
+ function resetView() {
370
+ network.unselectAll();
371
+ clearTimeout(_deselectTimer);
372
+ hidePanel();
373
+ scheduleDim(null);
374
+ }
375
+
282
376
  // ── Controls ─────────────────────────────────────────
283
377
  document.getElementById('reset-btn').onclick = () => {
284
378
  resetView();
285
379
  network.fit({ animation: { duration: 350, easingFunction: 'easeInOutQuad' } });
286
380
  };
287
381
 
382
+ // Index for fast search lookup
383
+ var searchIndex = data.nodes.map((n) => ({
384
+ id: n.id,
385
+ hay: `${String(n.id)} ${n.label || ''} ${n.title || ''}`.toLowerCase(),
386
+ }));
288
387
  document.getElementById('search').oninput = (e) => {
289
- const q = e.target.value.toLowerCase();
388
+ const q = e.target.value.toLowerCase().trim();
290
389
  if (!q) {
291
390
  resetView();
292
391
  return;
293
392
  }
294
- for (const n of nodes.get()) {
295
- const orig = data.nodes.find((x) => x.id === n.id);
296
- const hit =
297
- String(n.id).toLowerCase().includes(q) ||
298
- (orig?.label ?? '').toLowerCase().includes(q) ||
299
- (orig?.title ?? '').toLowerCase().includes(q);
300
- nodes.update({ id: n.id, opacity: hit ? 1 : 0.08 });
301
- }
393
+ nodes.update(searchIndex.map((n) => ({ id: n.id, opacity: n.hay.includes(q) ? 1 : 0.08 })));
302
394
  };
303
395
 
396
+ // Index scenarios by pass/fail for fast filtering
397
+ var scenarioIndex = data.nodes
398
+ .filter((n) => n.group === 'Scenario')
399
+ .map((n) => ({ id: n.id, isPass: n.color === '#10B981', isFail: n.color === '#EF4444' }));
400
+ function applyFilters() {
401
+ var pass = document.getElementById('filter-pass').checked;
402
+ var fail = document.getElementById('filter-fail').checked;
403
+ if (!scenarioIndex.length) return;
404
+ nodes.update(
405
+ scenarioIndex.map((n) => ({ id: n.id, hidden: (n.isPass && !pass) || (n.isFail && !fail) })),
406
+ );
407
+ }
304
408
  ['filter-pass', 'filter-fail', 'filter-p0'].forEach((id) => {
305
- document.getElementById(id).onchange = () => {
306
- const pass = document.getElementById('filter-pass').checked;
307
- const fail = document.getElementById('filter-fail').checked;
308
- for (const n of nodes.get()) {
309
- if (n.group !== 'Scenario') continue;
310
- const orig = data.nodes.find((x) => x.id === n.id);
311
- const isPass = orig?.color === '#10B981';
312
- const isFail = orig?.color === '#EF4444';
313
- const hidden = (isPass && !pass) || (isFail && !fail);
314
- nodes.update({ id: n.id, hidden });
315
- }
316
- };
409
+ document.getElementById(id).onchange = applyFilters;
317
410
  });
411
+
412
+ // ── Cross-tab navigation hook (used by Coverage drawer) ───
413
+ window.__xeraFocus = (id) => {
414
+ if (!id || !nodes.get(id)) return;
415
+ network.unselectAll();
416
+ network.selectNodes([id]);
417
+ showPanel(id);
418
+ scheduleDim(id);
419
+ network.focus(id, {
420
+ scale: 1.3,
421
+ animation: { duration: 450, easingFunction: 'easeInOutQuad' },
422
+ });
423
+ };
318
424
  })();
319
425
 
320
426
  // v0.8.1 — top-level tab switching
@@ -375,41 +481,297 @@ function renderCoverageOnce() {
375
481
  renderCoverageMap();
376
482
  }
377
483
 
378
- // Task 27 — coverage map: area color overlay
484
+ // Task 27 — coverage map: QA action queue (3 sections + drawer)
485
+ const COV_STATUS_THEME = {
486
+ UNCOVERED: { fill: '#3d1515', border: '#f87171', glow: 'rgba(239, 68, 68, 0.45)' },
487
+ STALE: { fill: '#3d2c0d', border: '#fbbf24', glow: 'rgba(245, 158, 11, 0.45)' },
488
+ COVERED: { fill: '#0d3320', border: '#34d399', glow: 'rgba(16, 185, 129, 0.4)' },
489
+ ATRISK: { fill: '#2a1e0a', border: '#fb923c', glow: 'rgba(251, 146, 60, 0.45)' },
490
+ };
491
+
492
+ function covEscape(s) {
493
+ return String(s).replace(
494
+ /[&<>"']/g,
495
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
496
+ );
497
+ }
498
+
499
+ function covTile(a, opts) {
500
+ const theme = COV_STATUS_THEME[opts.themeKey || a.status] || COV_STATUS_THEME.COVERED;
501
+ const heat = opts.heat;
502
+ const pulse = opts.pulse ? ' data-pulse="true"' : '';
503
+ const id = covEscape(a.id);
504
+ return (
505
+ `<article class="cov-tile" data-area-id="${id}" data-status="${a.status}"${pulse} ` +
506
+ `style="--fill:${theme.fill};--border:${theme.border};--glow:${theme.glow};--heat:${heat}" ` +
507
+ `tabindex="0" role="button" aria-label="${id} — ${a.status.toLowerCase()}, risk ${a.risk}">` +
508
+ `<header class="cov-tile-head"><span class="cov-tile-status">${opts.statusLabel || a.status.toLowerCase()}</span>` +
509
+ `<span class="cov-tile-risk" title="risk score">${a.risk}</span></header>` +
510
+ `<h4 class="cov-tile-name">${id}</h4>` +
511
+ `<dl class="cov-tile-meta">` +
512
+ `<div><dt>tickets</dt><dd>${a.breakdown.recentTickets}</dd></div>` +
513
+ `<div><dt>bugs</dt><dd>${a.breakdown.recentBugs}</dd></div>` +
514
+ `</dl></article>`
515
+ );
516
+ }
517
+
518
+ function covSection(opts) {
519
+ const tilesHtml = opts.tiles.join('');
520
+ const head =
521
+ `<header class="cov-section-head"><span class="cov-section-icon ${opts.iconClass}"></span>` +
522
+ `<h3 class="cov-section-title">${opts.title}</h3>` +
523
+ `<span class="cov-section-count">${opts.count}</span>` +
524
+ `<span class="cov-section-desc">${opts.desc}</span></header>`;
525
+ if (opts.collapsed) {
526
+ return `<details class="cov-section cov-section-collapsible"><summary>${head}</summary><div class="cov-grid">${tilesHtml}</div></details>`;
527
+ }
528
+ return `<section class="cov-section">${head}<div class="cov-grid">${tilesHtml}</div></section>`;
529
+ }
530
+
379
531
  function renderCoverageMap() {
380
532
  const cov = window.__COVERAGE__;
381
- if (!cov || !window.__GRAPH__) return;
533
+ if (!cov) return;
382
534
  const canvas = document.getElementById('coverage-map-canvas');
383
535
  if (!canvas) return;
536
+ canvas.innerHTML = '';
384
537
 
385
- const STATUS_COLOR = {
386
- UNCOVERED: { background: '#fca5a5', border: '#dc2626' },
387
- STALE: { background: '#fcd34d', border: '#d97706' },
388
- COVERED: { background: '#86efac', border: '#15803d' },
389
- };
390
- const NEUTRAL = { background: '#e5e7eb', border: '#9ca3af' };
538
+ if (!cov.report.areas.length) {
539
+ canvas.innerHTML =
540
+ '<p class="cov-empty">No SUT areas tracked yet — run <code>/xera-fetch</code> on a ticket with acceptance criteria to populate.</p>';
541
+ return;
542
+ }
391
543
 
392
- const areaStatusById = {};
393
- for (const a of cov.report.areas) {
394
- areaStatusById[a.id] = a.status;
544
+ const areas = cov.report.areas;
545
+ const needs = areas
546
+ .filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE')
547
+ .sort((a, b) => b.risk - a.risk);
548
+ const covered = areas.filter((a) => a.status === 'COVERED').sort((a, b) => b.risk - a.risk);
549
+ // "At risk": top 1/3 of covered by risk (min 1, only if risk > 0)
550
+ const atRiskCount = covered.length ? Math.max(1, Math.ceil(covered.length / 3)) : 0;
551
+ const atRisk = covered.slice(0, atRiskCount).filter((a) => a.risk > 0);
552
+ const healthy = covered.filter((a) => !atRisk.includes(a));
553
+ const topRisk = areas.reduce((m, a) => (a.risk > m.risk ? a : m), areas[0]);
554
+ const maxRisk = Math.max(...areas.map((a) => a.risk), 1);
555
+ const heatFor = (a) => 0.35 + 0.65 * (a.risk / maxRisk);
556
+
557
+ // Summary bar
558
+ const urgent = needs.length ? ' cov-summary-stat-urgent' : '';
559
+ const summary =
560
+ `<div class="cov-summary">` +
561
+ `<div class="cov-summary-stat${urgent}"><span class="cov-summary-num">${needs.length}</span><span class="cov-summary-label">need action</span></div>` +
562
+ `<div class="cov-summary-divider"></div>` +
563
+ `<div class="cov-summary-stat"><span class="cov-summary-num">${atRisk.length}</span><span class="cov-summary-label">at risk</span></div>` +
564
+ `<div class="cov-summary-divider"></div>` +
565
+ `<div class="cov-summary-stat"><span class="cov-summary-num">${healthy.length}</span><span class="cov-summary-label">healthy</span></div>` +
566
+ `<div class="cov-summary-top">` +
567
+ `<span class="cov-summary-top-label">top risk</span>` +
568
+ `<button class="cov-summary-top-btn" data-area-id="${covEscape(topRisk.id)}">${covEscape(topRisk.id)} <span class="cov-summary-top-risk">${topRisk.risk}</span></button>` +
569
+ `</div>` +
570
+ `</div>`;
571
+
572
+ let html = summary;
573
+
574
+ if (needs.length) {
575
+ const tiles = needs.map((a, idx) =>
576
+ covTile(a, { heat: heatFor(a), pulse: idx === 0 && a.risk > 0 }),
577
+ );
578
+ html += covSection({
579
+ title: 'Needs attention',
580
+ desc: 'Write new tests or refresh stale ones',
581
+ iconClass: 'cov-section-icon-urgent',
582
+ count: needs.length,
583
+ tiles,
584
+ });
395
585
  }
396
586
 
397
- const mappedNodes = window.__GRAPH__.nodes.map((n) => {
398
- if (n.group === 'SUTArea' && areaStatusById[n.id]) {
399
- return Object.assign({}, n, { color: STATUS_COLOR[areaStatusById[n.id]] });
400
- }
401
- if (n.group !== 'SUTArea') return Object.assign({}, n, { color: NEUTRAL });
402
- return n;
587
+ if (atRisk.length) {
588
+ const tiles = atRisk.map((a) =>
589
+ covTile(a, {
590
+ themeKey: 'ATRISK',
591
+ statusLabel: 'at risk',
592
+ heat: heatFor(a),
593
+ }),
594
+ );
595
+ html += covSection({
596
+ title: 'At risk',
597
+ desc: 'Covered, but recently changed — re-run scenarios after each merge',
598
+ iconClass: 'cov-section-icon-warn',
599
+ count: atRisk.length,
600
+ tiles,
601
+ });
602
+ }
603
+
604
+ if (healthy.length) {
605
+ const tiles = healthy.map((a) => covTile(a, { heat: heatFor(a) }));
606
+ html += covSection({
607
+ title: 'Healthy',
608
+ desc: 'Low recent activity, well-covered',
609
+ iconClass: 'cov-section-icon-ok',
610
+ count: healthy.length,
611
+ tiles,
612
+ collapsed: needs.length > 0 || atRisk.length > 0, // collapse if there's anything actionable above
613
+ });
614
+ }
615
+
616
+ canvas.innerHTML = html;
617
+ attachCovHandlers();
618
+ }
619
+
620
+ // ── Coverage drawer ──────────────────────────────────────
621
+ function attachCovHandlers() {
622
+ document.querySelectorAll('.cov-tile').forEach((t) => {
623
+ t.addEventListener('click', () => openCovDrawer(t.dataset.areaId));
624
+ t.addEventListener('keydown', (e) => {
625
+ if (e.key === 'Enter' || e.key === ' ') {
626
+ e.preventDefault();
627
+ openCovDrawer(t.dataset.areaId);
628
+ }
629
+ });
630
+ });
631
+ document.querySelectorAll('.cov-summary-top-btn').forEach((b) => {
632
+ b.addEventListener('click', () => openCovDrawer(b.dataset.areaId));
403
633
  });
634
+ const closeBtn = document.getElementById('cov-drawer-close');
635
+ if (closeBtn && !closeBtn.dataset.bound) {
636
+ closeBtn.dataset.bound = '1';
637
+ closeBtn.addEventListener('click', closeCovDrawer);
638
+ document.addEventListener('keydown', (e) => {
639
+ if (e.key === 'Escape') closeCovDrawer();
640
+ });
641
+ }
642
+ }
404
643
 
405
- new vis.Network(
406
- canvas,
407
- { nodes: new vis.DataSet(mappedNodes), edges: new vis.DataSet(window.__GRAPH__.edges) },
408
- {
409
- physics: { enabled: true, stabilization: { iterations: 100 } },
410
- nodes: { shape: 'dot', font: { size: 11 } },
411
- },
644
+ function openCovDrawer(areaId) {
645
+ const cov = window.__COVERAGE__;
646
+ const graph = window.__GRAPH__;
647
+ if (!cov || !graph) return;
648
+ const area = cov.report.areas.find((a) => a.id === areaId);
649
+ if (!area) return;
650
+
651
+ const drawer = document.getElementById('cov-drawer');
652
+ const status = document.getElementById('cov-drawer-status');
653
+ const title = document.getElementById('cov-drawer-title');
654
+ const body = document.getElementById('cov-drawer-body');
655
+ if (!drawer || !status || !title || !body) return;
656
+
657
+ const theme = COV_STATUS_THEME[area.status] || COV_STATUS_THEME.COVERED;
658
+ status.textContent = area.status.toLowerCase();
659
+ status.style.color = theme.border;
660
+ status.style.background = `${theme.fill}`;
661
+ status.style.borderColor = theme.border;
662
+ title.textContent = area.id;
663
+
664
+ // Find connected nodes (1-hop) from graph
665
+ const connected = new Set();
666
+ for (const e of graph.edges) {
667
+ if (e.from === areaId) connected.add(e.to);
668
+ if (e.to === areaId) connected.add(e.from);
669
+ }
670
+ const nodesById = {};
671
+ for (const n of graph.nodes) nodesById[n.id] = n;
672
+ const connectedTickets = [...connected].filter((id) => nodesById[id]?.group === 'Ticket');
673
+ const connectedScenarios = [...connected].filter((id) => nodesById[id]?.group === 'Scenario');
674
+
675
+ const passCount = connectedScenarios.filter((id) => nodesById[id]?.color !== '#EF4444').length;
676
+ const failCount = connectedScenarios.length - passCount;
677
+
678
+ // AC gaps among connected tickets
679
+ const acGaps = (cov.report.tickets || []).filter(
680
+ (t) => connectedTickets.includes(t.id) && t.unsatisfiedAcs?.length,
412
681
  );
682
+
683
+ const riskBreakdown = `
684
+ <section class="cov-drawer-section">
685
+ <h4>Risk breakdown</h4>
686
+ <div class="cov-drawer-risk">
687
+ <div class="cov-drawer-risk-num">${area.risk}</div>
688
+ <ul class="cov-drawer-risk-meta">
689
+ <li><span>${area.breakdown.recentTickets}</span> recent tickets</li>
690
+ <li><span>${area.breakdown.recentBugs}</span> recent bugs</li>
691
+ ${area.breakdown.criticalBoost ? '<li class="cov-drawer-risk-crit">⚠ critical area</li>' : ''}
692
+ </ul>
693
+ </div>
694
+ </section>`;
695
+
696
+ const scenariosSection = connectedScenarios.length
697
+ ? `<section class="cov-drawer-section">
698
+ <h4>Scenarios <span class="cov-drawer-count">${connectedScenarios.length}</span></h4>
699
+ <div class="cov-drawer-pillrow">
700
+ ${passCount ? `<span class="cov-drawer-pill cov-pill-pass">${passCount} passing</span>` : ''}
701
+ ${failCount ? `<span class="cov-drawer-pill cov-pill-fail">${failCount} failing</span>` : ''}
702
+ </div>
703
+ <ul class="cov-drawer-list">
704
+ ${connectedScenarios
705
+ .map((id) => {
706
+ const n = nodesById[id];
707
+ const fail = n?.color === '#EF4444';
708
+ return `<li><button class="cov-drawer-item" data-focus-id="${covEscape(id)}"><i class="cov-dot ${fail ? 'cov-dot-fail' : 'cov-dot-pass'}"></i><span>${covEscape(n?.label || id)}</span></button></li>`;
709
+ })
710
+ .join('')}
711
+ </ul>
712
+ </section>`
713
+ : '';
714
+
715
+ const ticketsSection = connectedTickets.length
716
+ ? `<section class="cov-drawer-section">
717
+ <h4>Tickets touching this area <span class="cov-drawer-count">${connectedTickets.length}</span></h4>
718
+ <ul class="cov-drawer-list">
719
+ ${connectedTickets
720
+ .map((id) => {
721
+ const n = nodesById[id];
722
+ return `<li><button class="cov-drawer-item" data-focus-id="${covEscape(id)}"><i class="cov-dot cov-dot-ticket"></i><span>${covEscape(id)}${n?.title ? ` — ${covEscape(n.title.replace(/^[A-Z]+-\d+\s*[—–-]\s*/, ''))}` : ''}</span></button></li>`;
723
+ })
724
+ .join('')}
725
+ </ul>
726
+ </section>`
727
+ : '';
728
+
729
+ const acSection = acGaps.length
730
+ ? `<section class="cov-drawer-section">
731
+ <h4>AC gaps <span class="cov-drawer-count">${acGaps.reduce((s, t) => s + t.unsatisfiedAcs.length, 0)}</span></h4>
732
+ <ul class="cov-drawer-list cov-drawer-list-stack">
733
+ ${acGaps
734
+ .map(
735
+ (t) =>
736
+ `<li><div class="cov-drawer-ac"><strong>${covEscape(t.id)}</strong> — ${t.satisfiedCount}/${t.acCount} covered<div class="cov-drawer-ac-tags">${t.unsatisfiedAcs.map((ac) => `<span class="cov-drawer-ac-tag">AC-${ac.index}</span>`).join('')}</div></div></li>`,
737
+ )
738
+ .join('')}
739
+ </ul>
740
+ </section>`
741
+ : '';
742
+
743
+ const actions = `
744
+ <section class="cov-drawer-actions">
745
+ <button class="cov-drawer-action" data-focus-id="${covEscape(area.id)}">
746
+ <span>View in graph</span>
747
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>
748
+ </button>
749
+ </section>`;
750
+
751
+ body.innerHTML = riskBreakdown + scenariosSection + ticketsSection + acSection + actions;
752
+
753
+ // Wire focus buttons → switch to Knowledge tab and select node
754
+ body.querySelectorAll('[data-focus-id]').forEach((b) => {
755
+ b.addEventListener('click', () => {
756
+ const id = b.dataset.focusId;
757
+ closeCovDrawer();
758
+ const knowledgeBtn = document.querySelector('.toplevel-tabs button[data-tab="knowledge"]');
759
+ if (knowledgeBtn && !knowledgeBtn.classList.contains('active')) knowledgeBtn.click();
760
+ requestAnimationFrame(() => {
761
+ if (typeof window.__xeraFocus === 'function') window.__xeraFocus(id);
762
+ });
763
+ });
764
+ });
765
+
766
+ drawer.classList.remove('hidden');
767
+ drawer.setAttribute('aria-hidden', 'false');
768
+ }
769
+
770
+ function closeCovDrawer() {
771
+ const drawer = document.getElementById('cov-drawer');
772
+ if (!drawer) return;
773
+ drawer.classList.add('hidden');
774
+ drawer.setAttribute('aria-hidden', 'true');
413
775
  }
414
776
 
415
777
  // Task 28 — coverage list: sortable area + AC gap tables
@@ -484,20 +846,78 @@ function renderCoverageTrend() {
484
846
  const n = snap.areas.filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE').length;
485
847
  return { day: d, value: n };
486
848
  });
849
+
850
+ // Single data point — render a quiet placeholder rather than a degenerate chart
851
+ if (points.length === 1) {
852
+ container.innerHTML =
853
+ `<div class="cov-trend-single">` +
854
+ `<span class="cov-trend-value">${points[0].value}</span>` +
855
+ `<span class="cov-trend-unit">uncovered + stale areas</span>` +
856
+ `<span class="cov-trend-date">${points[0].day}</span>` +
857
+ `<p class="cov-trend-hint">Run /xera-coverage on subsequent days to build a trend line.</p>` +
858
+ `</div>`;
859
+ return;
860
+ }
861
+
487
862
  const W = 800;
488
- const H = 200;
489
- const PAD = 30;
490
- const maxValue = Math.max(...points.map((p) => p.value), 1);
491
- const stepX = points.length > 1 ? (W - 2 * PAD) / (points.length - 1) : 0;
492
- const path = points
493
- .map((p, idx) => {
494
- const x = PAD + idx * stepX;
495
- const y = H - PAD - (p.value / maxValue) * (H - 2 * PAD);
496
- return `${idx === 0 ? 'M' : 'L'}${x},${y}`;
497
- })
498
- .join(' ');
863
+ const H = 260;
864
+ const PAD_L = 40;
865
+ const PAD_R = 24;
866
+ const PAD_T = 16;
867
+ const PAD_B = 32;
868
+ const innerW = W - PAD_L - PAD_R;
869
+ const innerH = H - PAD_T - PAD_B;
870
+ const rawMax = Math.max(...points.map((p) => p.value), 1);
871
+ // Round maxValue up to a "nice" integer so y-axis labels are clean integers
872
+ const niceMax = rawMax <= 4 ? rawMax : Math.ceil(rawMax / 5) * 5;
873
+ const stepX = innerW / (points.length - 1);
874
+ const xy = points.map((p, idx) => ({
875
+ x: PAD_L + idx * stepX,
876
+ y: PAD_T + innerH - (p.value / niceMax) * innerH,
877
+ v: p.value,
878
+ d: p.day,
879
+ }));
880
+ const linePath = xy.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
881
+ const areaPath = `${linePath} L${xy[xy.length - 1].x},${PAD_T + innerH} L${xy[0].x},${PAD_T + innerH} Z`;
882
+
883
+ // Horizontal grid lines — pick step that yields integer labels
884
+ const tickCount = Math.min(niceMax, 4);
885
+ const tickStep = niceMax / tickCount;
886
+ const seenTicks = new Set();
887
+ const grid = [];
888
+ for (let i = 1; i <= tickCount; i++) {
889
+ const v = Math.round(tickStep * i);
890
+ if (seenTicks.has(v)) continue;
891
+ seenTicks.add(v);
892
+ const y = PAD_T + innerH - (v / niceMax) * innerH;
893
+ grid.push(
894
+ `<line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="#1a2540" stroke-width="1" stroke-dasharray="2 4"/>` +
895
+ `<text x="${PAD_L - 8}" y="${y + 3}" font-size="10" text-anchor="end">${v}</text>`,
896
+ );
897
+ }
898
+ // Baseline 0 tick
899
+ grid.push(
900
+ `<line x1="${PAD_L}" y1="${PAD_T + innerH}" x2="${W - PAD_R}" y2="${PAD_T + innerH}" stroke="#1e2d45" stroke-width="1"/>` +
901
+ `<text x="${PAD_L - 8}" y="${PAD_T + innerH + 3}" font-size="10" text-anchor="end">0</text>`,
902
+ );
903
+
904
+ const dots = xy
905
+ .map(
906
+ (p) =>
907
+ `<circle cx="${p.x}" cy="${p.y}" r="3" fill="#ef4444" stroke="#080c14" stroke-width="1.5"><title>${p.d}: ${p.v} uncovered/stale</title></circle>`,
908
+ )
909
+ .join('');
499
910
 
500
911
  const labelFirst = points[0].day;
501
912
  const labelLast = points[points.length - 1].day;
502
- container.innerHTML = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"><path d="${path}" fill="none" stroke="#dc2626" stroke-width="2"/><text x="${PAD}" y="${H - 8}" font-size="11" fill="#6b7280">${labelFirst}</text><text x="${W - PAD - 60}" y="${H - 8}" font-size="11" fill="#6b7280">${labelLast}</text><text x="${PAD - 22}" y="${PAD - 4}" font-size="11" fill="#6b7280">${maxValue}</text></svg>`;
913
+ container.innerHTML =
914
+ `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">` +
915
+ `<defs><linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#ef4444" stop-opacity="0.25"/><stop offset="100%" stop-color="#ef4444" stop-opacity="0"/></linearGradient></defs>` +
916
+ grid.join('') +
917
+ `<path d="${areaPath}" fill="url(#trendFill)" stroke="none"/>` +
918
+ `<path d="${linePath}" fill="none" stroke="#ef4444" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
919
+ dots +
920
+ `<text x="${PAD_L}" y="${H - 8}" font-size="10">${labelFirst}</text>` +
921
+ `<text x="${W - PAD_R}" y="${H - 8}" font-size="10" text-anchor="end">${labelLast}</text>` +
922
+ `</svg>`;
503
923
  }