mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-28 07:42:47 +00:00
redesign(#1644): make observer comparison feel amazing — themed button vocabulary + state-preserving multi-select + Tufte-grade compare page (#1645)
## What was wrong PR #1642 promoted observer comparison to a first-class IA citizen but shipped three problems: `class="btn-secondary"` buttons that fell back to browser-default white/gray because no such CSS rule existed; the 30-second auto-refresh blew away `<tbody>.innerHTML` and destroyed every compare-select checkbox along with its state; and the `#/compare` page itself showed three card-boxes forcing the eye to do mental subtraction. ## Design rationale (Tufte) The comparison page now leads with **one row of three numbers above one proportional diff bar** — shared-axis small multiples in place of three nearly-identical cards. The eye reads the whole comparison in one fixation. Asymmetric reach is demoted from two big cards to two compact, ctx-style sentences with mono-numeric percentages. The button vocabulary borrows route-view v2's restraint: surface tokens for neutral chrome, accent only on the primary CTA, no gradients or shadows. The checkbox column visually recedes when no row is picked (empty-state IS the design) and lights up only once a selection exists. Everything composes existing CSS tokens — no new top-level color literals — so all themes (light, dark, CB presets) Just Work. ## Inventory of CSS additions | Selector | Role | |---|---| | `.btn-secondary`, `.btn-secondary[disabled]` | Themed neutral button (low-emphasis CTA) | | `.btn-ghost` | Minimal transparent-until-hover variant (reserved for future) | | `.compare-page`, `.compare-page .page-header` | Page-level container, overrides `.page-header { justify-content: space-between }` | | `.compare-breadcrumbs` | Themed breadcrumb link strip | | `.compare-controls`, `.compare-selector`, `.compare-select-group`, `.compare-select`, `.compare-vs`, `.compare-btn` | Selector strip — re-themed with surface tokens | | `.compare-strip`, `.compare-strip-row`, `.compare-strip-side`, `.compare-strip-mid`, `.compare-strip-name`, `.compare-strip-count`, `.compare-strip-mid-count`, `.compare-strip-mid-label`, `.compare-strip-sub` | The headline small-multiples row (A \| shared \| B) | | `.compare-bar`, `.compare-bar-seg`, `.compare-bar-{a,both,b}`, `.compare-bar-legend`, `.compare-legend-item`, `.compare-dot-{a,both,b}` | Single proportional diff bar | | `.compare-asym`, `.compare-asym-line`, `.compare-asym-pct` | Compact directional-reach sentences (replaced the two big cards) | | `.compare-type-summary`, `.compare-type-summary-label`, `.compare-type-badge` | Shared-type pill row with ctx-style border-left accent | | `.compare-tabs`, `.compare-tabs .tab-btn`, `.tab-btn.active` | Tabs reskinned to match the muted-then-accent pattern | | `.compare-summary-text`, `.compare-warning`, `.compare-good` | Themed status notes | | `.col-compare-select`, `.col-compare-select input[type="checkbox"]` | Compare-select column — muted when empty, full text + `--selected-bg` row tint when populated | | `.obs-table.has-compare-selection` | Marker class so the column changes intensity only when something is picked | | `.observers-page .page-header`, `.obs-refresh-spacer` | Header layout (flex with right-side refresh icon) | | `.observer-detail-page .compare-with-group` | Grouped picker + Compare button surface on the detail page | **Tokens used:** `--surface-1`, `--surface-2`, `--border`, `--accent`, `--accent-hover`, `--text`, `--text-muted`, `--row-hover`, `--hover-bg`, `--selected-bg`, `--status-green`, `--status-amber`, `--status-amber-light`, `--status-amber-text`, `--radius-sm`, `--radius-md`, `--badge-radius`, `--space-xs..xl`, `--fs-sm..xl`, `--mono`. **No new top-level color tokens were introduced.** ## Before PR #1642's bare `<button class="btn-secondary">` rendered with the browser-default white pill and the compare page showed three rgba-tinted cards (`rgba(34,197,94,0.1)`, `rgba(74,158,255,0.1)`, `rgba(255,107,107,0.1)`) — chartjunk with no theme awareness. See #1644 description for the bug repro. ## After (screenshots) **Desktop — observers page (light, empty + selected states):** - Empty: `MEDIA: 42d90aa5-643c-4e88-8b5d-3383cfa2dfe4.jpg` - Two selected (rows tinted, button enabled): `MEDIA: a6d9b397-ffe5-4eeb-b07b-ef89041ab6ea.jpg` **Desktop — observer detail (light, picker + Compare grouped):** `MEDIA: 17b9b47d-5e97-4293-8558-e9b37c244335.jpg` **Desktop — compare page (light, real data via mock — fixture has 0 overlap):** `MEDIA: be169bf2-f31b-480a-97b1-4f678745471b.jpg` **Desktop — compare page (dark):** `MEDIA: 436477a7-600c-4ac4-aa9d-97db968246d3.jpg` **Desktop — observers (dark, two selected):** `MEDIA: 850242c3-db77-460f-895f-0a6e6b150758.jpg` **Mobile 375px — observers (dark):** `MEDIA: 338b543c-0705-41ec-95da-e2c2a8db2065.jpg` **Mobile 375px — compare page (dark, stacks cleanly):** `MEDIA: 380a984c-26f0-4f47-b4ba-d655571721c9.jpg` ## Test plan - `node test-issue-1644-redesign.js` — 8/8 (new behavioral suite for this PR) - `node test-issue-1562-observers-summary.js` — 13/13 - `node test-compare-overlap.js` — 6/6 - `node test-compare-flood-filter.js` — 6/6 - `node test-frontend-helpers.js` — 611/611 - `node scripts/check-css-vars.js` — 0 undefined refs across 1901 var() calls - Browser-validated against local fixture build at `localhost:13580`: desktop light/dark, mobile 375px light/dark, observers + detail + compare pages. Checkbox preservation verified by manual refresh click — state survives the tbody rewrite. ## TDD - Red commit: `94e019c5` — 7 behavioral assertions that all FAIL on master (no top-level `.btn-secondary`, no `preserveCompareSelection` helper, rgba literals in compare-card rules). - Green commit: `a246208d` — implementation. All 8 assertions pass (the rgba assertion was relaxed to a conditional check after the cards were removed entirely in favor of the strip; an additional `.compare-strip exists` assertion was added). ## Out of scope - The server-side `&since=...` parser is strict about RFC3339 and rejects the `.000Z` suffix the frontend emits; this means the comparison page shows zeros against any data > 24h old. Filed separately — not a regression introduced by this PR. Screenshots showing populated numbers use a `comparePacketSets` test stub. - Backend Go untouched. Fixes #1644 --------- Co-authored-by: clawbot <clawbot@users.noreply.github.com> Co-authored-by: openclaw-bot <bot@openclaw>
This commit is contained in:
+127
-74
@@ -100,12 +100,12 @@ if (typeof window !== 'undefined') {
|
||||
currentView = 'summary';
|
||||
routeFilter = 'all';
|
||||
|
||||
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
|
||||
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">' +
|
||||
app.innerHTML = '<div class="compare-page">' +
|
||||
'<div class="page-header">' +
|
||||
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
|
||||
'<h2 style="margin:0">\uD83D\uDD0D Observer Comparison</h2>' +
|
||||
'<h2>\uD83D\uDD0D Observer Comparison</h2>' +
|
||||
'</div>' +
|
||||
'<nav data-role="compare-breadcrumbs" aria-label="Compare breadcrumbs" class="compare-breadcrumbs" style="margin:0 0 12px 0;font-size:0.9em;color:var(--text-muted)"></nav>' +
|
||||
'<nav data-role="compare-breadcrumbs" aria-label="Compare breadcrumbs" class="compare-breadcrumbs"></nav>' +
|
||||
'<div id="compareControls" class="compare-controls"><div class="text-center text-muted" style="padding:20px">Loading observers\u2026</div></div>' +
|
||||
'<div id="compareContent"></div>' +
|
||||
'</div>';
|
||||
@@ -313,76 +313,139 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
var typeHtml = Object.keys(typeBreakdown).map(function (t) {
|
||||
return '<span class="compare-type-badge">' +
|
||||
escapeHtml(PAYLOAD_LABELS[t] || 'Type ' + t) + ': ' + typeBreakdown[t] +
|
||||
escapeHtml(PAYLOAD_LABELS[t] || 'Type ' + t) + ' <b>' + typeBreakdown[t] + '</b>' +
|
||||
'</span>';
|
||||
}).join(' ');
|
||||
}).join('');
|
||||
|
||||
var stats = computeOverlapStats(r);
|
||||
|
||||
content.innerHTML =
|
||||
'<div class="compare-results">' +
|
||||
// Summary cards
|
||||
'<div class="compare-summary">' +
|
||||
'<div class="compare-card compare-card-both" data-view="both">' +
|
||||
'<div class="compare-card-count">' + r.both.length.toLocaleString() + '</div>' +
|
||||
'<div class="compare-card-label">Seen by both</div>' +
|
||||
'<div class="compare-card-pct">' + pctBoth + '%</div>' +
|
||||
// Headline strip — A | shared | B above a single proportional bar.
|
||||
// One row of large numbers + one shared-axis bar = the comparison
|
||||
// in a single glance. Replaces three card-boxes that forced the
|
||||
// eye to do mental subtraction.
|
||||
'<section class="compare-strip" aria-label="Packet overlap summary">' +
|
||||
'<div class="compare-strip-row">' +
|
||||
'<div class="compare-strip-side" data-view="onlyA" role="button" tabindex="0" aria-label="Show only ' + nameA + ' packets">' +
|
||||
'<div class="compare-strip-name">' + nameA + '</div>' +
|
||||
'<div class="compare-strip-count">' + stats.totalA.toLocaleString() + '</div>' +
|
||||
'<div class="compare-strip-sub">' + r.onlyA.length.toLocaleString() + ' only here (' + pctA + '%)</div>' +
|
||||
'</div>' +
|
||||
'<div class="compare-strip-mid" data-view="both" role="button" tabindex="0" aria-label="Show shared packets">' +
|
||||
'<div class="compare-strip-mid-label">shared</div>' +
|
||||
'<div class="compare-strip-mid-count">' + r.both.length.toLocaleString() + '</div>' +
|
||||
'<div class="compare-strip-sub">' + pctBoth + '% of all unique</div>' +
|
||||
'</div>' +
|
||||
'<div class="compare-strip-side compare-strip-side-b" data-view="onlyB" role="button" tabindex="0" aria-label="Show only ' + nameB + ' packets">' +
|
||||
'<div class="compare-strip-name">' + nameB + '</div>' +
|
||||
'<div class="compare-strip-count">' + stats.totalB.toLocaleString() + '</div>' +
|
||||
'<div class="compare-strip-sub">' + r.onlyB.length.toLocaleString() + ' only here (' + pctB + '%)</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="compare-card compare-card-a" data-view="onlyA">' +
|
||||
'<div class="compare-card-count">' + r.onlyA.length.toLocaleString() + '</div>' +
|
||||
'<div class="compare-card-label">Only ' + nameA + '</div>' +
|
||||
'<div class="compare-card-pct">' + pctA + '%</div>' +
|
||||
// Single shared-axis diff bar. Width is exact proportion.
|
||||
'<div class="compare-bar-container">' +
|
||||
'<div class="compare-bar" role="img"' +
|
||||
' aria-label="' + nameA + ' only ' + pctA + '%, both ' + pctBoth + '%, ' + nameB + ' only ' + pctB + '%">' +
|
||||
(pctA > 0 ? '<div class="compare-bar-seg compare-bar-a" style="width:' + pctA + '%" title="Only ' + nameA + ': ' + r.onlyA.length + '"></div>' : '') +
|
||||
(pctBoth > 0 ? '<div class="compare-bar-seg compare-bar-both" style="width:' + pctBoth + '%" title="Both: ' + r.both.length + '"></div>' : '') +
|
||||
(pctB > 0 ? '<div class="compare-bar-seg compare-bar-b" style="width:' + pctB + '%" title="Only ' + nameB + ': ' + r.onlyB.length + '"></div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="compare-bar-legend">' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-a"></span> ' + nameA + ' only</span>' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-both"></span> Both</span>' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-b"></span> ' + nameB + ' only</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="compare-card compare-card-b" data-view="onlyB">' +
|
||||
'<div class="compare-card-count">' + r.onlyB.length.toLocaleString() + '</div>' +
|
||||
'<div class="compare-card-label">Only ' + nameB + '</div>' +
|
||||
'<div class="compare-card-pct">' + pctB + '%</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</section>' +
|
||||
|
||||
// Visual bar
|
||||
'<div class="compare-bar-container">' +
|
||||
'<div class="compare-bar">' +
|
||||
(pctA > 0 ? '<div class="compare-bar-seg compare-bar-a" style="width:' + pctA + '%" title="Only ' + nameA + ': ' + r.onlyA.length + '"></div>' : '') +
|
||||
(pctBoth > 0 ? '<div class="compare-bar-seg compare-bar-both" style="width:' + pctBoth + '%" title="Both: ' + r.both.length + '"></div>' : '') +
|
||||
(pctB > 0 ? '<div class="compare-bar-seg compare-bar-b" style="width:' + pctB + '%" title="Only ' + nameB + ': ' + r.onlyB.length + '"></div>' : '') +
|
||||
// Asymmetric reach — two compact sentences instead of two big cards
|
||||
'<section class="compare-asym" aria-label="Directional reach">' +
|
||||
'<div class="compare-asym-line">' +
|
||||
'<span class="compare-asym-pct">' + stats.aSeesOfB.toFixed(1) + '%</span>' +
|
||||
nameA + ' saw <b>' + stats.shared.toLocaleString() + '</b> of ' + nameB +
|
||||
'\u2019s <b>' + stats.totalB.toLocaleString() + '</b> packets' +
|
||||
'</div>' +
|
||||
'<div class="compare-bar-legend">' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-a"></span> ' + nameA + ' only</span>' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-both"></span> Both</span>' +
|
||||
'<span class="compare-legend-item"><span class="compare-dot compare-dot-b"></span> ' + nameB + ' only</span>' +
|
||||
'<div class="compare-asym-line">' +
|
||||
'<span class="compare-asym-pct">' + stats.bSeesOfA.toFixed(1) + '%</span>' +
|
||||
nameB + ' saw <b>' + stats.shared.toLocaleString() + '</b> of ' + nameA +
|
||||
'\u2019s <b>' + stats.totalA.toLocaleString() + '</b> packets' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</section>' +
|
||||
|
||||
// Type breakdown for shared packets
|
||||
(typeHtml ? '<div class="compare-type-summary"><strong>Shared packet types:</strong> ' + typeHtml + '</div>' : '') +
|
||||
// Shared packet types — pills, mono-numeric
|
||||
(typeHtml ? '<section class="compare-type-summary" aria-label="Shared packet types">' +
|
||||
'<span class="compare-type-summary-label">Shared types</span>' + typeHtml +
|
||||
'</section>' : '') +
|
||||
|
||||
// Detail tabs
|
||||
'<div class="compare-tabs">' +
|
||||
'<button class="tab-btn' + (currentView === 'summary' ? ' active' : '') + '" data-cview="summary">Summary</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'both' ? ' active' : '') + '" data-cview="both">Both (' + r.both.length + ')</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'onlyA' ? ' active' : '') + '" data-cview="onlyA">Only ' + nameA + ' (' + r.onlyA.length + ')</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'onlyB' ? ' active' : '') + '" data-cview="onlyB">Only ' + nameB + ' (' + r.onlyB.length + ')</button>' +
|
||||
'<div class="compare-tabs" role="tablist">' +
|
||||
'<button class="tab-btn' + (currentView === 'summary' ? ' active' : '') + '" data-cview="summary" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'summary' ? 'true' : 'false') + '" tabindex="' + (currentView === 'summary' ? '0' : '-1') + '">Summary</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'both' ? ' active' : '') + '" data-cview="both" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'both' ? 'true' : 'false') + '" tabindex="' + (currentView === 'both' ? '0' : '-1') + '">Both (' + r.both.length + ')</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'onlyA' ? ' active' : '') + '" data-cview="onlyA" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'onlyA' ? 'true' : 'false') + '" tabindex="' + (currentView === 'onlyA' ? '0' : '-1') + '">Only ' + nameA + ' (' + r.onlyA.length + ')</button>' +
|
||||
'<button class="tab-btn' + (currentView === 'onlyB' ? ' active' : '') + '" data-cview="onlyB" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'onlyB' ? 'true' : 'false') + '" tabindex="' + (currentView === 'onlyB' ? '0' : '-1') + '">Only ' + nameB + ' (' + r.onlyB.length + ')</button>' +
|
||||
'</div>' +
|
||||
'<div id="compareDetail"></div>' +
|
||||
'</div>';
|
||||
|
||||
// Bind tab clicks
|
||||
content.addEventListener('click', function handler(e) {
|
||||
var btn = e.target.closest('[data-cview]');
|
||||
if (btn) {
|
||||
currentView = btn.dataset.cview;
|
||||
content.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
renderDetail();
|
||||
// Sync the tablist's active/aria-selected/tabindex to currentView.
|
||||
function syncTabState() {
|
||||
content.querySelectorAll('.tab-btn').forEach(function (b) {
|
||||
var on = b.dataset.cview === currentView;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
b.setAttribute('tabindex', on ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
// Activate a [data-view] strip segment OR a [data-cview] tab.
|
||||
function activate(el) {
|
||||
if (!el) return;
|
||||
if (el.dataset.cview) {
|
||||
currentView = el.dataset.cview;
|
||||
} else if (el.dataset.view) {
|
||||
currentView = el.dataset.view;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Clickable summary cards
|
||||
var card = e.target.closest('[data-view]');
|
||||
if (card) {
|
||||
currentView = card.dataset.view;
|
||||
content.querySelectorAll('.tab-btn').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.cview === currentView);
|
||||
});
|
||||
renderDetail();
|
||||
syncTabState();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
// Bind tab clicks + strip clicks
|
||||
content.addEventListener('click', function handler(e) {
|
||||
var btn = e.target.closest('[data-cview]');
|
||||
if (btn) { activate(btn); return; }
|
||||
var seg = e.target.closest('[data-view]');
|
||||
if (seg) { activate(seg); }
|
||||
});
|
||||
|
||||
// Keyboard activation for tabs (arrow nav) + strip segments (Enter/Space).
|
||||
content.addEventListener('keydown', function (e) {
|
||||
var tab = e.target.closest('[data-cview]');
|
||||
if (tab) {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var tabs = Array.prototype.slice.call(content.querySelectorAll('.tab-btn'));
|
||||
var idx = tabs.indexOf(tab);
|
||||
if (idx < 0) return;
|
||||
var next = e.key === 'ArrowRight'
|
||||
? tabs[(idx + 1) % tabs.length]
|
||||
: tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
activate(next);
|
||||
next.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
activate(tab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
var seg = e.target.closest('[data-view]');
|
||||
if (seg && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
activate(seg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -397,28 +460,18 @@ if (typeof window !== 'undefined') {
|
||||
var nameB = escapeHtml(obsName(selB));
|
||||
|
||||
if (currentView === 'summary') {
|
||||
// Textual summary
|
||||
var stats = computeOverlapStats(r);
|
||||
// Textual summary — the headline strip + asym lines already cover
|
||||
// the quantitative story; this paragraph adds context and surfaces
|
||||
// edge cases (no shared packets, perfect overlap).
|
||||
var total = r.onlyA.length + r.onlyB.length + r.both.length;
|
||||
var overlap = total > 0 ? (r.both.length / total * 100).toFixed(1) : '0.0';
|
||||
el.innerHTML =
|
||||
'<div class="compare-summary-text">' +
|
||||
'<p>In the last 24 hours, <strong>' + nameA + '</strong> saw <strong>' + stats.totalA.toLocaleString() + '</strong> unique packets ' +
|
||||
'and <strong>' + nameB + '</strong> saw <strong>' + stats.totalB.toLocaleString() + '</strong> unique packets.</p>' +
|
||||
// #671 — asymmetric reference-observer comparison
|
||||
'<div class="compare-asymmetric" style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0">' +
|
||||
'<div class="compare-asym-card" style="flex:1;min-width:240px;padding:12px;border:1px solid var(--border, #333);border-radius:6px">' +
|
||||
'<div style="font-size:1.6em;font-weight:bold">' + stats.aSeesOfB.toFixed(1) + '%</div>' +
|
||||
'<div class="text-muted">' + nameA + ' saw <strong>' + stats.shared.toLocaleString() + '</strong> of ' + nameB + '\u2019s ' + stats.totalB.toLocaleString() + ' packets</div>' +
|
||||
'</div>' +
|
||||
'<div class="compare-asym-card" style="flex:1;min-width:240px;padding:12px;border:1px solid var(--border, #333);border-radius:6px">' +
|
||||
'<div style="font-size:1.6em;font-weight:bold">' + stats.bSeesOfA.toFixed(1) + '%</div>' +
|
||||
'<div class="text-muted">' + nameB + ' saw <strong>' + stats.shared.toLocaleString() + '</strong> of ' + nameA + '\u2019s ' + stats.totalA.toLocaleString() + ' packets</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<p><strong>' + r.both.length.toLocaleString() + '</strong> packets (' + overlap + '%) were seen by both observers. ' +
|
||||
'<strong>' + r.onlyA.length.toLocaleString() + '</strong> were exclusive to ' + nameA + ' and ' +
|
||||
'<strong>' + r.onlyB.length.toLocaleString() + '</strong> were exclusive to ' + nameB + '.</p>' +
|
||||
'<p>In the last 24 hours, <strong>' + nameA + '</strong> saw <strong>' +
|
||||
(r.onlyA.length + r.both.length).toLocaleString() + '</strong> unique packets ' +
|
||||
'and <strong>' + nameB + '</strong> saw <strong>' +
|
||||
(r.onlyB.length + r.both.length).toLocaleString() + '</strong> unique packets. ' +
|
||||
'<strong>' + r.both.length.toLocaleString() + '</strong> (' + overlap + '%) were seen by both observers.</p>' +
|
||||
(r.both.length === 0 && total > 0 ? '<p class="compare-warning">\u26A0\uFE0F These observers share no packets \u2014 they may be on different frequencies or too far apart.</p>' : '') +
|
||||
(r.onlyA.length === 0 && r.onlyB.length === 0 && r.both.length > 0 ? '<p class="compare-good">\u2705 Perfect overlap \u2014 both observers see the same packets.</p>' : '') +
|
||||
'</div>';
|
||||
|
||||
+13
-12
@@ -87,18 +87,19 @@ window.ObserverDetailNaiveBanner = {
|
||||
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
|
||||
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
|
||||
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<label class="sr-only" for="obsCompareWithPicker">Compare with another observer</label>
|
||||
<select id="obsCompareWithPicker" data-action="compare-with-picker"
|
||||
aria-label="Compare with another observer"
|
||||
title="Pick another observer to compare against"
|
||||
class="time-range-select">
|
||||
<option value="">Compare with…</option>
|
||||
</select>
|
||||
<button type="button" data-action="compare-with-go" class="btn-secondary" disabled aria-disabled="true"
|
||||
title="Open side-by-side comparison"
|
||||
style="display:inline-flex;align-items:center;gap:6px">
|
||||
<span aria-hidden="true">🔍</span><span>Compare</span>
|
||||
</button>
|
||||
<span class="compare-with-group">
|
||||
<label class="sr-only" for="obsCompareWithPicker">Compare with another observer</label>
|
||||
<select id="obsCompareWithPicker" data-action="compare-with-picker"
|
||||
aria-label="Compare with another observer"
|
||||
title="Pick another observer to compare against"
|
||||
class="time-range-select">
|
||||
<option value="">Compare with…</option>
|
||||
</select>
|
||||
<button type="button" data-action="compare-with-go" class="btn-secondary" disabled aria-disabled="true"
|
||||
title="Open side-by-side comparison">
|
||||
<span aria-hidden="true">🔍</span><span>Compare</span>
|
||||
</button>
|
||||
</span>
|
||||
<select id="obsDaysSelect" class="time-range-select" aria-label="Time range">
|
||||
<option value="1">24 Hours</option>
|
||||
<option value="3">3 Days</option>
|
||||
|
||||
+52
-5
@@ -108,6 +108,31 @@ window.ObserversSummary = (function () {
|
||||
return { computeCounts: computeCounts, renderHeader: renderHeader };
|
||||
})();
|
||||
|
||||
// #1644 — preserveCompareSelection
|
||||
//
|
||||
// Why: renderObservers() rewrites <tbody>.innerHTML on every 30s
|
||||
// refresh (and on every WS-driven repaint), which destroys every
|
||||
// `<input data-compare-select>` node along with its checked state.
|
||||
// That manifested as "checkboxes randomly uncheck each other" — it
|
||||
// wasn't sibling interference, it was the whole tbody being replaced
|
||||
// underneath an active selection.
|
||||
//
|
||||
// The fix is surgical: snapshot the set of ids that were checked
|
||||
// BEFORE we touch the DOM, then walk the new boxes after `innerHTML=`
|
||||
// and re-set .checked on any whose `value` (observer id) was in the
|
||||
// snapshot. O(n) over visible rows.
|
||||
//
|
||||
// Pure helper so it's testable in jsdom-less Node (see
|
||||
// test-issue-1644-redesign.js). DO NOT inline this in render() —
|
||||
// the unit test introspects the global by name.
|
||||
window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbody) {
|
||||
if (!tbody || !prevIds || typeof tbody.querySelectorAll !== 'function') return;
|
||||
var boxes = tbody.querySelectorAll('input[data-compare-select]');
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
if (prevIds.has(boxes[i].value)) boxes[i].checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
(function () {
|
||||
let observers = [];
|
||||
let _fetchedAt = 0; // #1562: ms epoch when the current `observers` payload was received
|
||||
@@ -125,17 +150,16 @@ window.ObserversSummary = (function () {
|
||||
<h2>Observer Status</h2>
|
||||
<button type="button" class="btn-secondary" data-action="compare-observers"
|
||||
title="Compare two observers side-by-side"
|
||||
aria-label="Compare observers"
|
||||
style="display:inline-flex;align-items:center;gap:6px">
|
||||
aria-label="Compare observers">
|
||||
<span aria-hidden="true">🔍</span><span>Compare observers</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" data-action="compare-selected"
|
||||
title="Select exactly two rows to compare"
|
||||
aria-label="Compare selected observers"
|
||||
aria-disabled="true" disabled
|
||||
style="display:inline-flex;align-items:center;gap:6px">
|
||||
aria-disabled="true" disabled>
|
||||
<span aria-hidden="true">⚖️</span><span>Compare selected (<span data-role="compare-count">0</span>)</span>
|
||||
</button>
|
||||
<span class="obs-refresh-spacer"></span>
|
||||
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
||||
</div>
|
||||
<div id="obsRegionFilter" class="region-filter-container"></div>
|
||||
@@ -300,6 +324,16 @@ window.ObserversSummary = (function () {
|
||||
const el = document.getElementById('obsContent');
|
||||
if (!el) return;
|
||||
|
||||
// #1644 — snapshot compare-selection BEFORE we rewrite tbody.innerHTML.
|
||||
// The 30s auto-refresh (and any WS-driven repaint) destroys every
|
||||
// checkbox node; without this snapshot the user's selection silently
|
||||
// vanished. See window.preserveCompareSelection above.
|
||||
var _prevSelected = new Set();
|
||||
var _prevBoxes = document.querySelectorAll(
|
||||
'#obsTable tbody input[data-compare-select]:checked'
|
||||
);
|
||||
for (var _i = 0; _i < _prevBoxes.length; _i++) _prevSelected.add(_prevBoxes[_i].value);
|
||||
|
||||
// Apply region filter
|
||||
const selectedRegions = RegionFilter.getSelected();
|
||||
const filtered = selectedRegions
|
||||
@@ -372,6 +406,14 @@ window.ObserversSummary = (function () {
|
||||
</table></div>`;
|
||||
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
|
||||
const obsTbl = document.getElementById('obsTable');
|
||||
// #1644 — restore previously-checked compare-select boxes.
|
||||
if (obsTbl && _prevSelected.size > 0) {
|
||||
var _tbody = obsTbl.querySelector('tbody');
|
||||
if (_tbody) window.preserveCompareSelection(_prevSelected, _tbody);
|
||||
}
|
||||
// Refresh the disabled/enabled state of "Compare selected (N)" against
|
||||
// the post-restore reality.
|
||||
updateCompareSelectedState();
|
||||
// #1056: fluid columns + +N hidden pill
|
||||
if (obsTbl && window.TableResponsive) {
|
||||
window.TableResponsive.register(obsTbl);
|
||||
@@ -409,8 +451,13 @@ window.ObserversSummary = (function () {
|
||||
}
|
||||
function updateCompareSelectedState() {
|
||||
var btn = document.querySelector('[data-action="compare-selected"]');
|
||||
if (!btn) return;
|
||||
var ids = collectSelectedIds();
|
||||
// #1644 — toggle a table-level marker so CSS can highlight the
|
||||
// checkbox column only when something is actually selected. Avoids
|
||||
// the column dominating the eye when nothing is picked.
|
||||
var tbl = document.getElementById('obsTable');
|
||||
if (tbl) tbl.classList.toggle('has-compare-selection', ids.length > 0);
|
||||
if (!btn) return;
|
||||
var countEl = btn.querySelector('[data-role="compare-count"]');
|
||||
if (countEl) countEl.textContent = String(ids.length);
|
||||
var enabled = ids.length === 2;
|
||||
|
||||
+331
-71
@@ -3326,109 +3326,369 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
|
||||
.packet-filter-input.filter-error { border-color: var(--status-red); }
|
||||
.packet-filter-input.filter-active { border-color: var(--status-green); }
|
||||
|
||||
/* === Observer Comparison (#/compare) === */
|
||||
.compare-controls { margin-bottom: 20px; }
|
||||
.compare-selector {
|
||||
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
|
||||
/* ============================================================
|
||||
* Generic themed button vocabulary (#1644).
|
||||
* ------------------------------------------------------------
|
||||
* PR #1642 introduced markup like <button class="btn-secondary"> in
|
||||
* observers.js / observer-detail.js but never shipped a top-level
|
||||
* `.btn-secondary` rule — only `.ch-modal-btn-secondary`. The buttons
|
||||
* fell back to the browser default (white/gray) and looked broken
|
||||
* against the themed UI.
|
||||
*
|
||||
* The fix is a small, three-tier vocabulary built out of existing
|
||||
* surface/border/text tokens — NO new top-level color tokens, no
|
||||
* gradient/shadow chartjunk. Companion to `.btn-primary` (#3329 above):
|
||||
*
|
||||
* .btn-primary — high-emphasis, accent-filled
|
||||
* .btn-secondary — low-emphasis, themed neutral on surface
|
||||
* .btn-ghost — minimal, transparent until hover
|
||||
*
|
||||
* Sized 32-px tall by default; the global 48-px touch-target rule
|
||||
* lifts the hit area on touch devices without breaking desktop
|
||||
* proportions. Sibling `.btn-icon` (already defined above) keeps
|
||||
* its existing semantics.
|
||||
* ============================================================ */
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
.compare-select-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.btn-secondary:hover:not([disabled]):not(:disabled) {
|
||||
background: var(--row-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn-secondary[disabled],
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
transition: background 120ms, color 120ms, border-color 120ms;
|
||||
}
|
||||
.btn-ghost:hover:not([disabled]):not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-ghost[disabled],
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* === Observer Comparison (#/compare) — Tufte redesign (#1644) ===
|
||||
* Design rationale: the old layout was three card-boxes side-by-side
|
||||
* (heavy borders, decorative rgba tints, three nearly-identical
|
||||
* 28px counts) forcing the eye to do mental subtraction. The
|
||||
* redesign borrows the route-view v2 vocabulary (surface tokens,
|
||||
* small-multiples, ctx pills, sparing accent) so the comparison
|
||||
* page belongs in the same product. A single proportional
|
||||
* diff-strip (Tufte's "show the data") makes the asymmetry
|
||||
* legible at a glance; the textual cards are demoted to subtext.
|
||||
*/
|
||||
.compare-page { padding: var(--space-md); max-width: 1200px; margin: 0 auto; }
|
||||
.compare-page .page-header {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
gap: var(--space-sm); margin-bottom: var(--space-sm);
|
||||
}
|
||||
.compare-page .page-header h2 { font-size: var(--fs-lg); margin: 0; }
|
||||
|
||||
.compare-breadcrumbs {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
.compare-breadcrumbs a { color: var(--accent); text-decoration: none; }
|
||||
.compare-breadcrumbs a:hover { text-decoration: underline; }
|
||||
|
||||
.compare-controls {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.compare-selector {
|
||||
display: flex; align-items: flex-end; gap: var(--space-sm); flex-wrap: wrap;
|
||||
}
|
||||
.compare-select-group { display: flex; flex-direction: column; gap: 2px; min-width: 180px; }
|
||||
.compare-select-group label {
|
||||
font-size: 12px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.3px; color: var(--text-muted);
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.6px; color: var(--text-muted);
|
||||
}
|
||||
.compare-select {
|
||||
padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 14px;
|
||||
min-width: 220px; cursor: pointer;
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--input-bg); color: var(--text); font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.compare-select:focus { border-color: var(--accent); outline: none; }
|
||||
.compare-vs {
|
||||
font-size: 18px; font-weight: 700; color: var(--text-muted);
|
||||
padding-bottom: 6px;
|
||||
font-size: 12px; font-weight: 700; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
/* Compare-now action — primary accent button, reuses .btn-primary visuals
|
||||
* but lives inside the controls strip with consistent height. */
|
||||
.compare-btn {
|
||||
padding: 8px 20px; border: none; border-radius: 6px;
|
||||
background: var(--accent); color: #fff; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
padding: 6px 14px; border: 1px solid var(--accent); border-radius: var(--radius-sm);
|
||||
background: var(--accent); color: #fff; font-size: var(--fs-sm); font-weight: 700;
|
||||
cursor: pointer; transition: background 120ms, opacity 120ms;
|
||||
}
|
||||
.compare-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.compare-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.compare-btn:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
.compare-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.compare-results { margin-top: 16px; }
|
||||
.compare-results { margin-top: var(--space-md); }
|
||||
|
||||
.compare-summary {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px; margin-bottom: 16px;
|
||||
/* ── Headline strip — the only large number on the page ───────────
|
||||
* Three counts above a single proportional bar (small multiples on
|
||||
* a shared axis: one strip, not three boxes). Eye sees the whole
|
||||
* comparison in one fixation. */
|
||||
.compare-strip {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.compare-card {
|
||||
padding: 16px; border-radius: 8px; text-align: center; cursor: pointer;
|
||||
border: 2px solid transparent; transition: border-color 0.15s, transform 0.1s;
|
||||
.compare-strip-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: var(--space-md);
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.compare-card:hover { transform: translateY(-2px); }
|
||||
.compare-card-count { font-size: 28px; font-weight: 700; }
|
||||
.compare-card-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||||
.compare-card-pct { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.compare-card-both {
|
||||
background: rgba(34, 197, 94, 0.1); border-color: rgba(34, 197, 94, 0.3);
|
||||
.compare-strip-side { display: flex; flex-direction: column; gap: 2px; cursor: pointer; }
|
||||
.compare-strip-side-b { text-align: right; }
|
||||
.compare-strip-name {
|
||||
font-size: var(--fs-sm); font-weight: 700; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.compare-card-both .compare-card-count { color: var(--status-green); }
|
||||
|
||||
.compare-card-a {
|
||||
background: rgba(74, 158, 255, 0.1); border-color: rgba(74, 158, 255, 0.3);
|
||||
.compare-strip-count {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--fs-xl); font-weight: 700;
|
||||
color: var(--text); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.compare-card-a .compare-card-count { color: var(--accent); }
|
||||
|
||||
.compare-card-b {
|
||||
background: rgba(255, 107, 107, 0.1); border-color: rgba(255, 107, 107, 0.3);
|
||||
.compare-strip-sub { font-size: 11px; color: var(--text-muted); }
|
||||
.compare-strip-mid {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
border-left: 1px dashed var(--border);
|
||||
border-right: 1px dashed var(--border);
|
||||
padding: 0 var(--space-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.compare-card-b .compare-card-count { color: var(--status-red); }
|
||||
|
||||
/* Comparison bar */
|
||||
.compare-bar-container { margin-bottom: 16px; }
|
||||
.compare-bar {
|
||||
display: flex; height: 24px; border-radius: 6px; overflow: hidden;
|
||||
background: var(--border);
|
||||
.compare-strip-mid-count {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--fs-xl); font-weight: 700;
|
||||
color: var(--accent); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.compare-bar-seg { transition: width 0.3s ease; }
|
||||
.compare-bar-a { background: var(--accent); }
|
||||
.compare-bar-both { background: var(--status-green); }
|
||||
.compare-bar-b { background: var(--status-red); }
|
||||
|
||||
.compare-bar-legend {
|
||||
display: flex; gap: 16px; margin-top: 8px; font-size: 12px;
|
||||
.compare-strip-mid-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.compare-legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.compare-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
.compare-dot-a { background: var(--accent); }
|
||||
.compare-dot-both { background: var(--status-green); }
|
||||
.compare-dot-b { background: var(--status-red); }
|
||||
|
||||
/* The diff bar — one row, three colored segments, shared axis. */
|
||||
.compare-bar-container { margin-top: 4px; }
|
||||
.compare-bar {
|
||||
display: flex; height: 10px; border-radius: 5px; overflow: hidden;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.compare-bar-seg { transition: width 240ms ease; }
|
||||
.compare-bar-a { background: var(--accent); }
|
||||
.compare-bar-both { background: var(--status-green); }
|
||||
.compare-bar-b { background: var(--status-amber); }
|
||||
/* Tick marks on the diff bar showing where the proportions land */
|
||||
.compare-bar-legend {
|
||||
display: flex; gap: var(--space-md); margin-top: 6px;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.compare-legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.compare-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.compare-dot-a { background: var(--accent); }
|
||||
.compare-dot-both { background: var(--status-green); }
|
||||
.compare-dot-b { background: var(--status-amber); }
|
||||
|
||||
/* ── Asymmetric reach sentences ───────────────────────────────────
|
||||
* Two compact lines telling the directional story without three
|
||||
* cards or two big numbers. Mono-numeric for at-a-glance compare. */
|
||||
.compare-asym {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.compare-asym-line {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.compare-asym-line b { color: var(--text); font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.compare-asym-line .compare-asym-pct {
|
||||
font-family: var(--mono); font-size: var(--fs-lg); font-weight: 700;
|
||||
color: var(--text); font-variant-numeric: tabular-nums;
|
||||
display: block; margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Type breakdown — packet-context ctx pills (route-view vocab) ─ */
|
||||
.compare-type-summary {
|
||||
margin-bottom: 16px; font-size: 13px; color: var(--text);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--status-green);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--fs-sm); color: var(--text);
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
||||
}
|
||||
.compare-type-summary-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px;
|
||||
color: var(--text-muted); font-weight: 700;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.compare-type-badge {
|
||||
display: inline-block; padding: 2px 8px; margin: 2px;
|
||||
border-radius: var(--badge-radius); background: var(--surface-0);
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
display: inline-flex; align-items: baseline; gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--badge-radius);
|
||||
background: var(--surface-2);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.compare-type-badge b { color: var(--accent); font-weight: 700; }
|
||||
|
||||
/* ── Tabs (Summary / Both / Only A / Only B) ──────────────────── */
|
||||
.compare-tabs {
|
||||
display: flex; gap: 0; margin-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border); flex-wrap: wrap;
|
||||
}
|
||||
.compare-tabs .tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 14px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 120ms, border-color 120ms;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.compare-tabs .tab-btn:hover { color: var(--text); }
|
||||
.compare-tabs .tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.compare-tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
|
||||
.compare-summary-text { padding: 12px 0; font-size: 14px; line-height: 1.6; }
|
||||
.compare-summary-text { padding: 4px 0 var(--space-sm); font-size: var(--fs-sm); line-height: 1.6; color: var(--text-muted); }
|
||||
.compare-summary-text p { margin: 0 0 8px; }
|
||||
.compare-warning { color: var(--status-yellow); font-weight: 600; }
|
||||
.compare-good { color: var(--status-green); font-weight: 600; }
|
||||
.compare-summary-text p b,
|
||||
.compare-summary-text p strong { color: var(--text); font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.compare-warning {
|
||||
color: var(--status-amber-text); background: var(--status-amber-light);
|
||||
border: 1px solid var(--status-amber); padding: 6px 10px; border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.compare-good {
|
||||
color: var(--status-green);
|
||||
border: 1px solid var(--status-green); padding: 6px 10px; border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compare-table { font-size: 13px; }
|
||||
.compare-table { font-size: var(--fs-sm); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-selector { flex-direction: column; align-items: stretch; }
|
||||
.compare-select-group { min-width: 0; }
|
||||
.compare-select { min-width: auto; width: 100%; }
|
||||
.compare-summary { grid-template-columns: 1fr; }
|
||||
.compare-vs { display: none; }
|
||||
.compare-strip-row { grid-template-columns: 1fr; gap: var(--space-sm); }
|
||||
.compare-strip-side-b { text-align: left; }
|
||||
.compare-strip-mid { border-left: none; border-right: none;
|
||||
border-top: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
|
||||
padding: var(--space-sm) 0; flex-direction: row; gap: 6px; align-items: baseline; }
|
||||
.compare-asym { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Observers page — compare-selection column + button hints ─── */
|
||||
.col-compare-select {
|
||||
width: 36px; text-align: center;
|
||||
/* Tufte: this column is administrative chrome, NOT data.
|
||||
Shrink it; use the muted text so it doesn't compete. */
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* The checkbox itself: theme-aware accent + minimum tap target
|
||||
* provided by the surrounding cell, not the input. */
|
||||
.col-compare-select input[type="checkbox"] {
|
||||
width: 16px; height: 16px; min-height: 16px;
|
||||
accent-color: var(--accent); cursor: pointer;
|
||||
/* When at least one row is selected, we add .has-compare-selection
|
||||
* to the table so the column highlight makes it obvious WHICH rows
|
||||
* are in the selection. */
|
||||
}
|
||||
.obs-table.has-compare-selection .col-compare-select { color: var(--text); }
|
||||
.obs-table tbody tr:has(input[data-compare-select]:checked) {
|
||||
background: var(--selected-bg);
|
||||
}
|
||||
/* Compact "Compare selected (0)" button: when nothing selected, fade
|
||||
* to background so it doesn't compete with the primary "Compare
|
||||
* observers" CTA. The disabled rule above already gives 0.5 opacity. */
|
||||
.observers-page .page-header {
|
||||
display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.observers-page .page-header h2 { font-size: var(--fs-lg); margin: 0 var(--space-sm) 0 0; }
|
||||
.observers-page .page-header .obs-refresh-spacer { margin-left: auto; }
|
||||
|
||||
/* Observer-detail header: the Compare-with picker + Compare button
|
||||
* should sit together visually. Reuses the same .btn-secondary. */
|
||||
.observer-detail-page .compare-with-group {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--surface-1); overflow: hidden;
|
||||
}
|
||||
.observer-detail-page .compare-with-group select.time-range-select {
|
||||
border: none; background: transparent; color: var(--text);
|
||||
font-size: var(--fs-sm); padding: 6px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
}
|
||||
.observer-detail-page .compare-with-group select.time-range-select:focus { outline: none; }
|
||||
.observer-detail-page .compare-with-group button[data-action="compare-with-go"] {
|
||||
border: none; border-radius: 0; background: var(--surface-2);
|
||||
}
|
||||
.observer-detail-page .compare-with-group button[data-action="compare-with-go"]:hover:not(:disabled) {
|
||||
background: var(--row-hover); color: var(--text);
|
||||
}
|
||||
|
||||
/* Neighbor graph canvas focus indicator for keyboard navigation */
|
||||
|
||||
+13
-10
@@ -892,16 +892,19 @@ async function run() {
|
||||
|
||||
// Test: Compare results show shared/unique breakdown (#129)
|
||||
await test('Compare results show shared/unique cards', async () => {
|
||||
// Results should be visible from previous test
|
||||
const cardBoth = await page.$('.compare-card-both');
|
||||
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
|
||||
const cardA = await page.$('.compare-card-a');
|
||||
assert(cardA, 'Should have "only A" card (.compare-card-a)');
|
||||
const cardB = await page.$('.compare-card-b');
|
||||
assert(cardB, 'Should have "only B" card (.compare-card-b)');
|
||||
// Verify counts are rendered (may be locale-formatted with commas)
|
||||
const counts = await page.$$eval('.compare-card-count', els => els.map(e => e.textContent.trim()));
|
||||
assert(counts.length >= 3, `Expected >=3 summary counts, got ${counts.length}`);
|
||||
// Results should be visible from previous test.
|
||||
// Redesign (#1644) replaced the 3-card layout with a proportional strip
|
||||
// (shared-axis small-multiples): two side segments (A-only / B-only)
|
||||
// flanking a middle segment (shared).
|
||||
const stripMid = await page.$('.compare-strip-mid');
|
||||
assert(stripMid, 'Should have "shared" strip middle (.compare-strip-mid)');
|
||||
const sides = await page.$$('.compare-strip-side');
|
||||
assert(sides.length >= 2, `Should have >=2 side strips (A-only + B-only), got ${sides.length}`);
|
||||
// Counts: 2 side counts + 1 mid count = 3 total outcome-group counts.
|
||||
const sideCounts = await page.$$eval('.compare-strip-count', els => els.map(e => e.textContent.trim()));
|
||||
const midCount = await page.$eval('.compare-strip-mid-count', el => el.textContent.trim());
|
||||
const counts = sideCounts.concat([midCount]);
|
||||
assert(counts.length >= 3, `Expected >=3 outcome-group counts, got ${counts.length}`);
|
||||
counts.forEach((c, i) => {
|
||||
assert(/^[\d,]+$/.test(c), `Count ${i} should be a number but got "${c}"`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Issue #1644 — Behavioral regression tests for the observer-comparison
|
||||
* redesign. Pure-Node, no Playwright; runs in <1s.
|
||||
*
|
||||
* Three behavioral guarantees:
|
||||
* 1. `.btn-secondary` exists as a TOP-LEVEL themed rule in style.css
|
||||
* (not just scoped inside the channel modal). It uses theme tokens
|
||||
* for background, border and color — never browser defaults (white/
|
||||
* #ccc), never invented top-level color literals.
|
||||
* 2. `.btn-secondary[disabled]`/`:disabled` is visually distinct
|
||||
* (opacity rule present).
|
||||
* 3. observers.js snapshots+restores the compare-selection checkbox
|
||||
* state across renders via a documented Set-based helper.
|
||||
* A pure helper `window.preserveCompareSelection(prevSet, tbody)`
|
||||
* re-checks any rows whose id appears in prevSet, and the renderer
|
||||
* calls it post-`innerHTML=` rewrite.
|
||||
*
|
||||
* These are the assertions the redesign must keep green. Aesthetic
|
||||
* verification (looks Tufte-grade) is screenshot-based, not asserted here.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const CSS = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
const OBS_JS = fs.readFileSync(path.join(__dirname, 'public/observers.js'), 'utf8');
|
||||
const COMPARE_JS = fs.readFileSync(path.join(__dirname, 'public/compare.js'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' \u2705 ' + name); }
|
||||
catch (e) { failed++; console.error(' \u274c ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
console.log('\n#1644 redesign — behavioral assertions\n');
|
||||
|
||||
// ── 1) Themed .btn-secondary at top level ────────────────────────────
|
||||
test('.btn-secondary defined as top-level CSS rule (not only .ch-modal-btn-secondary)', () => {
|
||||
// Match a top-level `.btn-secondary` or `.btn-secondary,` selector — NOT
|
||||
// `.ch-modal-btn-secondary` which is an entirely different prefix.
|
||||
const re = /(^|[\s,{}])\.btn-secondary(\s*[,{:.\s])/m;
|
||||
assert(re.test(CSS), '.btn-secondary rule missing from public/style.css');
|
||||
});
|
||||
|
||||
test('.btn-secondary uses theme tokens (background var(--*) and color var(--*))', () => {
|
||||
// Find the rule block that declares .btn-secondary at top level and
|
||||
// verify it composes var(--…) tokens rather than hex/named browser
|
||||
// defaults. We extract any block whose selector list contains
|
||||
// `.btn-secondary` (with no preceding alphanum so we don't pick up
|
||||
// `.ch-modal-btn-secondary`).
|
||||
const blocks = CSS.match(/(?:^|\n)([^{}\n]*\.btn-secondary[^{}\n]*)\{([^}]*)\}/g) || [];
|
||||
// Filter out the .ch-modal-btn-secondary mention (different rule)
|
||||
const own = blocks.filter(b => /(^|[\s,])\.btn-secondary(\s*[,{:.\s])/.test(b));
|
||||
assert(own.length > 0, 'no own-rule block found for .btn-secondary');
|
||||
const joined = own.join('\n');
|
||||
assert(/background\s*:\s*[^;]*var\(--/.test(joined),
|
||||
'.btn-secondary background must reference a CSS variable');
|
||||
assert(/color\s*:\s*[^;]*var\(--/.test(joined),
|
||||
'.btn-secondary color must reference a CSS variable');
|
||||
// Tufte: no decorative gradients/shadows on a secondary button
|
||||
assert(!/linear-gradient|box-shadow:\s*[^n]/.test(joined.replace(/box-shadow:\s*none/g, '')),
|
||||
'.btn-secondary must not introduce chartjunk (gradient/shadow)');
|
||||
});
|
||||
|
||||
test('.btn-secondary disabled state is visually distinct (opacity rule)', () => {
|
||||
assert(/\.btn-secondary[^{]*(?:\[disabled\]|:disabled)[^{]*\{[^}]*opacity\s*:/.test(CSS),
|
||||
'.btn-secondary[disabled] / :disabled needs an opacity declaration');
|
||||
});
|
||||
|
||||
// ── 2) Compare card surfaces de-junked ───────────────────────────────
|
||||
// The redesign replaces the three card-boxes with a single proportional
|
||||
// strip + diff bar (Tufte: shared-axis small-multiples). If any
|
||||
// compare-card variants survive, they must use theme tokens, not raw
|
||||
// rgba() literals. The historic rule with `rgba(34, 197, 94, 0.1)` etc.
|
||||
// is the regression we're guarding against.
|
||||
test('compare-card surfaces (if present) use theme tokens — no raw rgba literals', () => {
|
||||
const cardRules = CSS.match(/\.compare-card-(?:a|b|both)[^{]*\{[^}]*\}/g) || [];
|
||||
cardRules.forEach(rule => {
|
||||
assert(!/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+/.test(rule),
|
||||
'compare-card rule has raw rgba() literal — must use theme tokens: ' + rule.slice(0, 100));
|
||||
});
|
||||
});
|
||||
|
||||
test('compare-strip exists in CSS as the headline data-display element', () => {
|
||||
assert(/\.compare-strip\b/.test(CSS),
|
||||
'expected .compare-strip rule (the small-multiples redesign of the comparison summary)');
|
||||
});
|
||||
|
||||
// ── 3) Checkbox-state preservation helper ────────────────────────────
|
||||
test('observers.js exposes window.preserveCompareSelection helper', () => {
|
||||
assert(/window\.preserveCompareSelection\s*=/.test(OBS_JS),
|
||||
'expected window.preserveCompareSelection helper to be defined');
|
||||
});
|
||||
|
||||
test('preserveCompareSelection re-checks rows whose id was previously selected', () => {
|
||||
// Minimal DOM shim: tbody.querySelectorAll('input[data-compare-select]').
|
||||
// Each checkbox has .value and .checked (mutable).
|
||||
function mkBox(id) {
|
||||
return {
|
||||
value: id,
|
||||
checked: false,
|
||||
_attrs: { 'data-compare-select': '' },
|
||||
hasAttribute(k) { return k in this._attrs; },
|
||||
};
|
||||
}
|
||||
const boxes = [mkBox('a'), mkBox('b'), mkBox('c')];
|
||||
const tbody = {
|
||||
querySelectorAll(sel) {
|
||||
assert(/data-compare-select/.test(sel), 'expected data-compare-select selector');
|
||||
return boxes;
|
||||
},
|
||||
};
|
||||
// Load observers.js in a vm with stub globals
|
||||
const sandbox = {
|
||||
window: {},
|
||||
document: { addEventListener() {}, querySelector() { return null; } },
|
||||
registerPage() {},
|
||||
debouncedOnWS() {},
|
||||
offWS() {},
|
||||
api() { return Promise.resolve({ observers: [] }); },
|
||||
CLIENT_TTL: {},
|
||||
setInterval() {}, clearInterval() {},
|
||||
RegionFilter: { init() {}, onChange() {}, offChange() {}, getSelected() { return null; } },
|
||||
SlideOver: null,
|
||||
location: { hash: '' },
|
||||
Date: Date, Math: Math, Number: Number, Set: Set, Map: Map, Array: Array, Object: Object,
|
||||
encodeURIComponent, console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(OBS_JS, sandbox);
|
||||
const fn = sandbox.window.preserveCompareSelection;
|
||||
assert(typeof fn === 'function', 'preserveCompareSelection missing');
|
||||
|
||||
const prev = new Set(['a', 'c']);
|
||||
fn(prev, tbody);
|
||||
assert(boxes[0].checked === true, 'a should be re-checked');
|
||||
assert(boxes[1].checked === false, 'b should NOT be checked');
|
||||
assert(boxes[2].checked === true, 'c should be re-checked');
|
||||
});
|
||||
|
||||
test('observers render() calls preserveCompareSelection after innerHTML rewrite', () => {
|
||||
// Strip block + line comments first so we're not satisfied by the
|
||||
// JSDoc/`// See window.preserveCompareSelection above.` references —
|
||||
// we want a REAL invocation, in the code path.
|
||||
const code = OBS_JS
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
assert(/preserveCompareSelection\s*\(/.test(code),
|
||||
'observers.js must INVOKE preserveCompareSelection in code (not just mention in a comment)');
|
||||
// And there must be a snapshot Set of previously-selected ids built
|
||||
// BEFORE the tbody is rewritten.
|
||||
assert(/:checked/.test(code) && /input\[data-compare-select\]/.test(code),
|
||||
'observers.js render must snapshot existing :checked compare-select boxes before innerHTML rewrite');
|
||||
});
|
||||
|
||||
// ── 4) a11y — tablist + clickable strip semantics (#1644 round-1) ────
|
||||
test('compare tab buttons declare aria-selected (synced with .active)', () => {
|
||||
// Tab markup is built as a string in compare.js. Each `<button class="tab-btn"
|
||||
// ... role="tab">` MUST also emit aria-selected so screen readers know which
|
||||
// tab is current. We grep for `aria-selected=` co-located with `role="tab"`.
|
||||
// A passing implementation will emit something like:
|
||||
// aria-selected="' + (currentView === 'both' ? 'true' : 'false') + '"
|
||||
const tabButtonBlocks = COMPARE_JS.match(/'<button class="tab-btn[^']*'[\s\S]{0,400}?role="tab"[^']*'/g) || [];
|
||||
assert(tabButtonBlocks.length >= 3, 'expected >=3 tab-btn strings with role="tab"');
|
||||
tabButtonBlocks.forEach((blk, i) => {
|
||||
assert(/aria-selected\s*=/.test(blk),
|
||||
'tab-btn block #' + i + ' missing aria-selected: ' + blk.slice(0, 120));
|
||||
});
|
||||
});
|
||||
|
||||
test('compare tab buttons declare aria-controls="compareDetail"', () => {
|
||||
const tabButtonBlocks = COMPARE_JS.match(/'<button class="tab-btn[^']*'[\s\S]{0,400}?role="tab"[^']*'/g) || [];
|
||||
tabButtonBlocks.forEach((blk, i) => {
|
||||
assert(/aria-controls=\\?"compareDetail\\?"/.test(blk),
|
||||
'tab-btn block #' + i + ' missing aria-controls="compareDetail"');
|
||||
});
|
||||
});
|
||||
|
||||
test('clickable compare-strip segments expose role="button" + tabindex', () => {
|
||||
// The strip rows carry data-view and are clicked via closest('[data-view]').
|
||||
// Without role=button and tabindex, keyboard/AT users can't activate them.
|
||||
// Find the three strip block strings (compare-strip-side, -mid, -side-b).
|
||||
const stripBlocks = COMPARE_JS.match(/'<div class="compare-strip-(?:side|mid)[^']*data-view[^']*'/g) || [];
|
||||
assert(stripBlocks.length >= 3, 'expected >=3 data-view strip blocks, got ' + stripBlocks.length);
|
||||
stripBlocks.forEach((blk, i) => {
|
||||
assert(/role=\\?"button\\?"/.test(blk),
|
||||
'strip block #' + i + ' must have role="button": ' + blk.slice(0, 120));
|
||||
assert(/tabindex=\\?"0\\?"/.test(blk),
|
||||
'strip block #' + i + ' must have tabindex="0": ' + blk.slice(0, 120));
|
||||
});
|
||||
});
|
||||
|
||||
test('compare.js binds keydown for Enter/Space activation of strip segments', () => {
|
||||
// We need a keydown handler that activates a [data-view] segment.
|
||||
assert(/addEventListener\(\s*['"]keydown['"]/.test(COMPARE_JS),
|
||||
'compare.js must bind a keydown handler for keyboard activation of strip segments');
|
||||
});
|
||||
|
||||
test('.compare-strip-side and .compare-strip-mid declare cursor:pointer in CSS', () => {
|
||||
// Tufte review noted only .compare-card had cursor:pointer; the strip
|
||||
// segments are now the clickable surface and need the same affordance.
|
||||
const sideRules = CSS.match(/\.compare-strip-side\b[^{]*\{[^}]*\}/g) || [];
|
||||
const midRules = CSS.match(/\.compare-strip-mid\b[^{]*\{[^}]*\}/g) || [];
|
||||
const all = sideRules.concat(midRules).join('\n');
|
||||
assert(/cursor\s*:\s*pointer/.test(all),
|
||||
'.compare-strip-side / .compare-strip-mid need cursor:pointer to signal clickability');
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed\n');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user