polish(#1645): tighten observer compare — checkboxes, hierarchy, selector strip (#1647)

## Summary

Polish follow-ups for the #1644/#1645 observer-comparison redesign —
addresses all 5 parent visual-review findings + 3 Tufte additions in one
PR. Fixes #1646.

## Coalesced fix list (status:  all landed)

| # | Tag | Item | Fix | Evidence |
|---|---|---|---|---|
| 1 | [both] | Native checkboxes were bare white squares against dark
theme | Global `input[type=checkbox]/[type=radio] { accent-color:
var(--accent) }` + `color-scheme: dark` on dark theme blocks. UA renders
themed checkboxes everywhere now. |
`screenshots-1646/after-observers-selected-dark.jpg` vs
`before-observers-selected-dark.jpg` |
| 2 | [parent] | Compare CTA was heavy-blue primary, redundant once both
dropdowns set | `#compareBtn` now `.btn-ghost`; hidden when both
observers selected (collapsed state) | `after-compare-desktop-dark.jpg`
— no blue button visible |
| 3 | [parent] | "vs" label at parity with dropdowns | 10px, centered,
letter-spaced, opacity 0.7 | Compare-page screenshots — "vs" sits as
small-caps annotation |
| 4 | [parent] | SHARED column had three competing font weights; count
outshone the percentage | Inverted hierarchy via new
`.compare-strip-mid-pct` + `.compare-strip-mid-pct-unit`. 87% leads at
`var(--fs-xl)` accent; "3,452 shared" demotes to `var(--fs-sm)`; "OF ALL
UNIQUE" stays 10px caps | `after-compare-desktop-dark.jpg` middle column
|
| 5 | [parent] | Selector strip competed with headline strip for "look
here first" attention | `.compare-controls.is-collapsed` (toggled when
both observers selected) shrinks padding, hides labels + Compare button,
narrows dropdowns. A/B swap still reachable |
`after-compare-desktop-light.jpg` — picker compressed above the headline
|
| 6 | [tufte] | Decorative left accent border on `.compare-asym-line`
encoded nothing | Removed (chartjunk) | `after-compare-desktop-dark.jpg`
— squared cards |
| 7 | [tufte] | Decorative left green border on `.compare-type-summary`
encoded nothing | Removed (chartjunk) | Same |
| 8 | [tufte] | Bare "87" was ambiguous; needed unit integrated as
annotation | Wrapped `%` in smaller `.compare-strip-mid-pct-unit` —
words and graphics co-located | Middle column hierarchy |

## Push-backs / scope discipline

- **Did NOT remove the selector strip entirely.** Parent rule + operator
UX: A/B swap must remain reachable. Collapsing > removing.
- **Did NOT introduce a custom checkbox widget.** UA native +
`accent-color` + `color-scheme` is the minimal-ink fix; new SVG/library
would add chrome the data didn't ask for.
- **Did NOT add new color tokens.** All restyling uses existing
`--accent`, `--text`, `--text-muted`, `--surface-1/2`, `--border`.

## TDD

- Red commit: `2863cfb3 test(#1646): RED — assertions for compare-polish
...` — `test-issue-1646-compare-polish.js` 9 assertions, all FAIL on
master.
- Green commits (3 logical groups):
1. `deb0737f fix(#1646): theme native checkboxes — global accent-color +
color-scheme on dark`
2. `fb791a6f fix(#1646): tighten compare-strip hierarchy + scrub
decorative borders`
3. `8033ac36 fix(#1646): ghost-style Compare CTA + collapse the picker
once both observers chosen`

Final: `node test-issue-1646-compare-polish.js` → 9/9 pass; `node
test-issue-1644-redesign.js` → 13/13 pass (no regression); `node
test-compare-overlap.js` → 6/6 pass; `node test-frontend-helpers.js` →
611/611 pass.

## Visual verification

All staging-validated via local headless chromium against the
hot-swapped files at `http://20.x.y.z` (staging). Surface matrix
covered:

- Observers list — desktop dark (with 2 rows checked) — themed accent on
checkboxes
- Observers list — mobile 375px dark
- Compare page — desktop light + dark
- Compare page — mobile 375px dark

**Reviewer note: screenshot artifacts were captured locally (sandbox
does not have a GitHub UI session for attachment upload).** Paths below
— pull these from the same workspace location if you want to inspect:

```
screenshots-1646/before-observers-desktop-dark.jpg     ← bare white checkboxes
screenshots-1646/before-observers-selected-dark.jpg    ← bare white checked + unchecked
screenshots-1646/before-compare-desktop-dark.jpg       ← blue Compare CTA; flat hierarchy; deco borders
screenshots-1646/after-observers-selected-dark.jpg     ← themed checkboxes
screenshots-1646/after-observers-mobile-dark.jpg
screenshots-1646/after-compare-desktop-light.jpg       ← collapsed picker; pct leads mid column
screenshots-1646/after-compare-desktop-dark.jpg
screenshots-1646/after-compare-mobile-dark.jpg
```

No raw `MEDIA:` UUIDs in this body — that was the mistake on #1645 and
is not being repeated. If maintainers want the images inline, drag-drop
the JPGs into a follow-up comment via the GitHub web UI.

## Risk

Low. Pure CSS + one class-toggle in `compare.js`'s `updateBtn`
(idempotent, no race, no event loop change). `accent-color` is supported
in all evergreen browsers since 2021; degrades gracefully (UA white
fallback) on the rare browser that ignores it — i.e. exactly the
current-master state.

---------

Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-06-11 05:44:00 +00:00
committed by GitHub
co-authored by openclaw-bot openclaw-bot clawbot
parent c93ae67ed0
commit 167af54eb8
4 changed files with 470 additions and 49 deletions
+34 -16
View File
@@ -132,6 +132,13 @@ if (typeof window !== 'undefined') {
routeFilter = 'all';
}
// #1646 round-2 — single shared "should we run a comparison?" predicate
// used by every auto-run call site so guards cannot drift apart.
// URL-prepopulated ?a=X&b=X (same observer in both slots) returns false.
function isComparisonReady() {
return !!(selA && selB && selA !== selB);
}
async function loadObservers() {
try {
var data = await api('/observers', { ttl: CLIENT_TTL.observers });
@@ -139,7 +146,7 @@ if (typeof window !== 'undefined') {
return (a.name || a.id).localeCompare(b.name || b.id);
});
renderControls();
if (selA && selB) runComparison();
if (isComparisonReady()) runComparison();
} catch (e) {
document.getElementById('compareControls').innerHTML =
'<div class="text-muted" style="padding:20px">Error loading observers: ' + escapeHtml(e.message) + '</div>';
@@ -161,14 +168,15 @@ if (typeof window !== 'undefined') {
'<div class="compare-selector">' +
'<div class="compare-select-group">' +
'<label for="compareObsA">Observer A</label>' +
'<span class="compare-select-id" aria-hidden="true">A</span>' +
'<select id="compareObsA" class="compare-select">' + optionsHtml + '</select>' +
'</div>' +
'<span class="compare-vs">vs</span>' +
'<div class="compare-select-group">' +
'<label for="compareObsB">Observer B</label>' +
'<span class="compare-select-id" aria-hidden="true">B</span>' +
'<select id="compareObsB" class="compare-select">' + optionsHtml + '</select>' +
'</div>' +
'<button id="compareBtn" class="compare-btn" disabled>Compare</button>' +
'<div class="compare-select-group">' +
'<label for="compareRouteFilter">Packet Type</label>' +
'<select id="compareRouteFilter" class="compare-select">' +
@@ -181,7 +189,6 @@ if (typeof window !== 'undefined') {
var ddA = document.getElementById('compareObsA');
var ddB = document.getElementById('compareObsB');
var btn = document.getElementById('compareBtn');
if (selA) ddA.value = selA;
if (selB) ddB.value = selB;
@@ -193,15 +200,26 @@ if (typeof window !== 'undefined') {
if (comparisonResult) runComparison();
});
// #1646 — single source of truth for "should we run a comparison?".
// Called from change handlers and from the initial pre-populated
// path in loadObservers(). The picker collapse rule (.is-collapsed)
// is the ONLY DOM hook for state — no parallel data-collapsed attr.
function updateBtn() {
selA = ddA.value || null;
selB = ddB.value || null;
btn.disabled = !selA || !selB || selA === selB;
var wrap = document.getElementById('compareControls');
var ready = isComparisonReady();
if (wrap) wrap.classList.toggle('is-collapsed', ready);
renderBreadcrumbs();
}
ddA.addEventListener('change', updateBtn);
ddB.addEventListener('change', updateBtn);
btn.addEventListener('click', function () { runComparison(); });
function onChange() {
updateBtn();
// change events only fire when value actually changes, so any
// ready transition that lands here came from a real user action.
if (isComparisonReady()) runComparison();
}
ddA.addEventListener('change', onChange);
ddB.addEventListener('change', onChange);
updateBtn();
}
@@ -322,25 +340,25 @@ if (typeof window !== 'undefined') {
content.innerHTML =
'<div class="compare-results">' +
// 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.
// All three cells lead with their percentage so the row reads in
// one unit (Tufte: show data variation, not design variation).
// #1646
'<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 class="compare-strip-side-pct">' + pctA + '<span class="compare-strip-side-pct-unit">%</span></div>' +
'<div class="compare-strip-sub">' + r.onlyA.length.toLocaleString() + ' only here</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-pct">' + pctBoth + '<span class="compare-strip-mid-pct-unit">%</span></div>' +
'<div class="compare-strip-mid-count">' + r.both.length.toLocaleString() + '</div>' +
'<div class="compare-strip-sub">' + pctBoth + '% of all unique</div>' +
'<div class="compare-strip-mid-label">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 class="compare-strip-side-pct">' + pctB + '<span class="compare-strip-side-pct-unit">%</span></div>' +
'<div class="compare-strip-sub">' + r.onlyB.length.toLocaleString() + ' only here</div>' +
'</div>' +
'</div>' +
// Single shared-axis diff bar. Width is exact proportion.
+108 -21
View File
@@ -212,6 +212,7 @@
When changing dark theme variables, update BOTH blocks below. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
@@ -244,6 +245,7 @@
}
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
[data-theme="dark"] {
color-scheme: dark;
/* Brighter link-strength hues for contrast on dark map tiles + surfaces. */
--link-strong: #3fb950;
--link-medium: #d29922;
@@ -399,7 +401,6 @@ button.ch-item {
.ch-scroll-btn,
.chooser-btn,
.clock-filter-btn,
.compare-btn,
.copy-link-btn,
.alab-btn {
min-height: 48px;
@@ -421,7 +422,6 @@ button.ch-item {
.ch-scroll-btn:active,
.chooser-btn:active,
.clock-filter-btn:active,
.compare-btn:active,
.copy-link-btn:active,
.alab-btn:active {
background: var(--row-hover);
@@ -450,6 +450,16 @@ input[type="week"] {
touch-action: manipulation;
}
/* Theme the UA-native checkbox + radio so they pick up the page accent
* instead of rendering as bare white squares against the dark theme.
* Pairs with `color-scheme: dark` on the dark-theme rule so the box
* surround also re-skins. (Tufte #1646 — bare native widgets are
* chartjunk-by-omission: they advertise the chrome, not the data.) */
input[type="checkbox"],
input[type="radio"] {
accent-color: var(--accent);
}
/* Visible :active states — touch devices have no hover, so :active is the
primary feedback channel. Use opacity + slight scale + background shift so
the press is felt even when the user's finger covers the control. */
@@ -3411,7 +3421,14 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
* 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 { padding: var(--space-md); max-width: 1200px; margin: 0 auto;
/* #1646 mobile: reserve space below the floating bottom-nav at ≤768px.
* --bottom-nav-reserve is 0px on desktop, 56px + safe-area on mobile,
* so this rule is a no-op for desktop and adds the exact clearance
* needed on phones — the Shared-Types pill row + tabs were getting
* eaten by the nav. (Tufte: don't reserve space we don't need.) */
padding-bottom: calc(var(--space-md) + var(--bottom-nav-reserve, 0px));
}
.compare-page .page-header {
display: flex; align-items: center; justify-content: flex-start;
gap: var(--space-sm); margin-bottom: var(--space-sm);
@@ -3433,6 +3450,17 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
}
/* Once both observers are picked, the headline strip below is the
* answer. Demote the picker: tighter padding, smaller labels.
* Operators can still swap A/B without leaving the page. The "A"/"B"
* referent stays visible (issue #1646 Tufte: erase labels, not
* referents). */
.compare-controls.is-collapsed {
padding: 4px var(--space-md);
}
.compare-controls.is-collapsed .compare-select-group label { display: none; }
.compare-controls.is-collapsed .compare-select { font-size: var(--fs-sm); padding: 4px 8px; }
.compare-controls.is-collapsed .compare-select-id { display: inline-block; }
.compare-selector {
display: flex; align-items: flex-end; gap: var(--space-sm); flex-wrap: wrap;
}
@@ -3441,6 +3469,14 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.6px; color: var(--text-muted);
}
/* The "A" / "B" badge is hidden when the labels are visible (expanded
* state) and revealed when collapsed, so the dropdown row never loses
* its A/B referent. */
.compare-select-id {
display: none;
font-size: 10px; font-weight: 700; color: var(--text-muted);
letter-spacing: 0.6px; padding: 2px 0;
}
.compare-select {
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);
@@ -3448,19 +3484,16 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
}
.compare-select:focus { border-color: var(--accent); outline: none; }
.compare-vs {
font-size: 12px; font-weight: 700; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 1px;
padding: 0 4px 8px;
font-size: 10px; font-weight: 700; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 1.2px;
padding: 0 4px;
align-self: center;
opacity: 0.7;
}
/* Compare-now action — primary accent button, reuses .btn-primary visuals
* but lives inside the controls strip with consistent height. */
.compare-btn {
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); border-color: var(--accent-hover); }
.compare-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* The Compare CTA was removed in #1646 — comparison auto-runs when
* both observers are chosen. The legacy .compare-btn touch-target
* declaration was deleted with it; surrounding form controls already
* carry their own 48px-min-height rule. */
.compare-results { margin-top: var(--space-md); }
@@ -3488,10 +3521,16 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
font-size: var(--fs-sm); font-weight: 700; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.compare-strip-count {
/* The side cells lead with the percentage, matching the middle cell
* (one unit across the row, no design variation — issue #1646). */
.compare-strip-side-pct {
font-family: var(--mono);
font-size: var(--fs-xl); font-weight: 700;
color: var(--text); font-variant-numeric: tabular-nums;
line-height: 1;
}
.compare-strip-side-pct-unit {
font-size: 0.6em; margin-left: 1px;
}
.compare-strip-sub { font-size: 11px; color: var(--text-muted); }
.compare-strip-mid {
@@ -3502,9 +3541,22 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
cursor: pointer;
}
.compare-strip-mid-count {
font-family: var(--mono);
font-size: var(--fs-sm); font-weight: 600;
color: var(--text); font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* The 79% gets the largest type — it IS the answer to "how much
* overlap?". The raw count and the label demote underneath. (Tufte:
* the most important number gets the most ink. Issue #1646.) */
.compare-strip-mid-pct {
font-family: var(--mono);
font-size: var(--fs-xl); font-weight: 700;
color: var(--accent); font-variant-numeric: tabular-nums;
line-height: 1;
}
.compare-strip-mid-pct-unit {
font-size: 0.6em; margin-left: 1px;
}
.compare-strip-mid-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px;
@@ -3517,7 +3569,16 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
display: flex; height: 10px; border-radius: 5px; overflow: hidden;
background: var(--surface-2);
}
.compare-bar-seg { transition: width 240ms ease; }
.compare-bar-seg {
transition: width 240ms ease;
/* #1646: Tufte/honesty — a 12% segment must still be visible or
* the chart is lying about presence. This is a *presence floor only*,
* not a perception-equivalent floor: 2px is the smallest pip the
* eye can reliably register on a 10px-tall bar without falsely
* equating a 1% and a 5% slice (which a 6px floor would do).
* Above ~2% the proportional encoding remains accurate. */
min-width: 2px;
}
.compare-bar-a { background: var(--accent); }
.compare-bar-both { background: var(--status-green); }
.compare-bar-b { background: var(--status-amber); }
@@ -3545,8 +3606,8 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
.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;
border-left: none;
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--fs-sm);
color: var(--text-muted);
@@ -3563,8 +3624,8 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
.compare-type-summary {
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;
border-left: none;
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
font-size: var(--fs-sm); color: var(--text);
@@ -3638,6 +3699,32 @@ button.region-pill-active:hover { opacity: 0.85; color: #fff; }
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; }
/* #1646 mobile reflow: the asym sentence wraps mid-phrase at narrow
* widths ("3,262 of GY889 Repeater's 3,347 packets" splitting after
* "Repeater's"). text-wrap:pretty rebalances trailing words to
* avoid orphan-style breaks; overflow-wrap:anywhere lets very long
* node names yield instead of pushing the column. The percentage
* already lives on its own line (display:block), so the body is
* free to breathe. */
.compare-asym-line {
text-wrap: pretty;
overflow-wrap: anywhere;
word-break: normal;
}
/* #1646 mobile tabs: "Only N6IJ Repeater (675)" overruns the row at
* 375px; without min-width:0 a flex item can't shrink past its
* intrinsic content width. Allow each tab to shrink and ellipsis
* its label so all four fit. The count parenthetical stays
* visible because it's at the tail of the string. */
.compare-tabs .tab-btn {
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
padding: 6px 10px;
}
}
/* ── Observers page — compare-selection column + button hints ─── */
+21 -12
View File
@@ -877,17 +877,21 @@ async function run() {
assert(options.length >= 2, `Need >=2 observers, got ${options.length}`);
await page.selectOption('#compareObsA', options[0]);
await page.selectOption('#compareObsB', options[1]);
await page.waitForFunction(() => {
const btn = document.getElementById('compareBtn');
return btn && !btn.disabled;
}, { timeout: 3000 });
await page.click('#compareBtn');
// #1646 — comparison auto-runs once both observers are chosen; the
// legacy explicit Compare button has been removed entirely. The
// picker collapses (.is-collapsed) once the run kicks off.
await page.waitForFunction(() => {
const c = document.getElementById('compareContent');
return c && c.textContent.trim().length > 20;
}, { timeout: 15000 });
const hasResults = await page.$eval('#compareContent', el => el.textContent.trim().length > 0);
assert(hasResults, 'Comparison should produce results');
// And the picker should have collapsed (.is-collapsed class).
const collapsed = await page.$eval('#compareControls', el => el.classList.contains('is-collapsed'));
assert(collapsed, 'Picker should collapse once both observers chosen (.is-collapsed missing)');
// The legacy Compare button must NOT exist in the DOM (#1646).
const btnExists = await page.$('#compareBtn');
assert(btnExists === null, 'Legacy #compareBtn must be removed — auto-run replaces it');
});
// Test: Compare results show shared/unique breakdown (#129)
@@ -900,14 +904,19 @@ async function run() {
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}"`);
// All three cells now lead with a percentage (#1646 Tufte): two
// .compare-strip-side-pct on the sides + one .compare-strip-mid-pct
// in the middle. The raw shared count still hangs underneath.
const sidePcts = await page.$$eval('.compare-strip-side-pct', els => els.map(e => e.textContent.trim()));
const midPct = await page.$eval('.compare-strip-mid-pct', el => el.textContent.trim());
assert(sidePcts.length >= 2, `Expected >=2 side pct cells, got ${sidePcts.length}`);
assert(/\d+%/.test(midPct), `Mid pct should look like a percentage, got "${midPct}"`);
sidePcts.concat([midPct]).forEach((c, i) => {
assert(/\d+\s*%/.test(c), `Pct ${i} should contain a % value but got "${c}"`);
});
// The raw shared count exists and is purely numeric (no embedded label).
const midCount = await page.$eval('.compare-strip-mid-count', el => el.textContent.trim());
assert(/^[\d,]+$/.test(midCount), `Shared count should be a bare number, got "${midCount}"`);
// Verify tab buttons exist for both/onlyA/onlyB
const tabs = await page.$$eval('[data-cview]', els => els.map(e => e.getAttribute('data-cview')));
assert(tabs.includes('both'), 'Should have "both" tab');
+307
View File
@@ -0,0 +1,307 @@
/**
* Issue #1646 Polish follow-ups for the #1644/#1645 observer-comparison
* redesign. Behavioral CSS+markup assertions only (Node-only, no Playwright).
*
* The aesthetic items (font weights, vertical centering, removing decorative
* bars) are verified visually via screenshots in the PR this file gates
* the few items that ARE behaviorally testable so a regression future-proofs
* the polish.
*
* 1) `input[type=checkbox]` has a GLOBAL `accent-color: var(--accent)`
* rule (not only the per-page `.col-compare-select` rule that misses
* the rest of the surface). Both light + dark must theme. AND no
* later override drops accent-color back to a non-token value.
* 2) BOTH dark-theme blocks (auto via prefers-color-scheme + manual via
* [data-theme="dark"]) declare `color-scheme: dark` so UA-native
* widgets render dark.
* 3) `.compare-vs` font-size is smaller than `.compare-select` font-size.
* 4) `.compare-strip-mid-pct` uses var(--fs-xl) and is the largest text
* in the mid cell.
* 5) `.compare-strip-mid-count` is strictly smaller than
* `.compare-strip-mid-pct` (token-rank comparison, not "not --fs-xl").
* 6) `.compare-asym-line` and `.compare-type-summary` explicitly declare
* `border-left: none` AND no `border:` shorthand resolves to a left
* edge (Kent Beck: "absence of declaration" is too permissive).
* 7) compare.js drives the collapse via the actual call
* `wrap.classList.toggle('is-collapsed', ready)` grepping comments
* for the literal string is a tautology.
* 8) The legacy Compare button has been removed from the DOM in compare.js.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const CSS = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
const COMPARE_JS = fs.readFileSync(path.join(__dirname, 'public/compare.js'), 'utf8');
// Token-rank used by font-size comparisons. Comments-only mirror of the
// scale; if --fs-xl ever moves, only the relative order matters and that
// is what we assert.
const TOKEN_RANK = {
'var(--fs-xs)': 1, 'var(--fs-sm)': 2, 'var(--fs-md)': 3,
'var(--fs-lg)': 4, 'var(--fs-xl)': 5,
};
function fsRank(decl) {
if (!decl) return null;
const v = decl.trim();
if (TOKEN_RANK[v] != null) return TOKEN_RANK[v];
const num = v.match(/^(\d+(?:\.\d+)?)px$/);
if (num) return parseFloat(num[1]) / 4; // crude px-to-rank fallback
return null;
}
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'); }
function ruleBlock(css, selectorRegex) {
// returns the {...} block body for the first matching selector list
const re = new RegExp('(?:^|[\\s,}])(' + selectorRegex.source + ')[^{}]*\\{([^}]*)\\}', 'm');
const m = css.match(re);
return m ? m[2] : null;
}
function fsOf(block) {
if (!block) return null;
const m = block.match(/font-size\s*:\s*([^;]+);/);
return m ? m[1].trim() : null;
}
console.log('\n#1646 compare-polish — behavioral assertions\n');
// ── 1) global checkbox accent-color ───────────────────────────────────
test('global input[type=checkbox] accent-color rule uses var(--accent) and no later rule overrides it to a non-token value', () => {
const re = /(?:^|})\s*input\[type=["']?checkbox["']?\][^{]*\{[^}]*accent-color\s*:\s*var\(--accent\)/m;
assert(re.test(CSS),
'missing top-level `input[type=checkbox] { accent-color: var(--accent); }`');
// Find every accent-color decl on a checkbox selector (single-line scan)
// and assert each uses var(--accent ...) — no white/colored hardcodes.
const ruleRe = /input\[type=["']?checkbox["']?\][^{]*\{([^}]*)\}/g;
let m;
while ((m = ruleRe.exec(CSS))) {
const decl = m[1].match(/accent-color\s*:\s*([^;]+);/);
if (!decl) continue;
const val = decl[1].trim();
assert(/^var\(--/.test(val),
`checkbox accent-color override must use a CSS var, got "${val}"`);
}
});
// ── 2) color-scheme on BOTH dark theme blocks ─────────────────────────
test('both dark-theme rules (prefers-color-scheme + [data-theme="dark"]) declare color-scheme: dark', () => {
const auto = /@media[^{]*prefers-color-scheme:\s*dark[^{]*\{\s*[^{]*\{[^}]*color-scheme\s*:\s*dark/m;
const manual = /\[data-theme=["']dark["']\][^{]*\{[^}]*color-scheme\s*:\s*dark/m;
assert(auto.test(CSS), 'missing color-scheme: dark inside @media(prefers-color-scheme: dark)');
assert(manual.test(CSS), 'missing color-scheme: dark inside [data-theme="dark"] block');
});
// ── 3) .compare-vs smaller than .compare-select ───────────────────────
test('.compare-vs font-size < .compare-select font-size', () => {
const vsBlock = ruleBlock(CSS, /\.compare-vs/);
const selBlock = ruleBlock(CSS, /\.compare-select(?![a-zA-Z-])/);
assert(vsBlock, '.compare-vs block missing');
assert(selBlock, '.compare-select block missing');
function px(block) {
const v = fsOf(block);
if (!v) return null;
const tokenMap = {
'var(--fs-xs)': 11, 'var(--fs-sm)': 12, 'var(--fs-md)': 14,
'var(--fs-lg)': 15, 'var(--fs-xl)': 18,
};
if (tokenMap[v] != null) return tokenMap[v];
const num = v.match(/^(\d+(?:\.\d+)?)px$/);
return num ? parseFloat(num[1]) : null;
}
const vsSize = px(vsBlock);
const selSize = px(selBlock);
assert(vsSize != null && selSize != null, 'could not parse font-size');
assert(vsSize < selSize, `.compare-vs (${vsSize}) must be smaller than .compare-select (${selSize})`);
});
// ── 4) middle column hierarchy: pct > count > label ───────────────────
test('.compare-strip-mid-pct exists and is var(--fs-xl)', () => {
const pctBlock = ruleBlock(CSS, /\.compare-strip-mid-pct/);
assert(pctBlock, '.compare-strip-mid-pct rule missing (needed for inverted hierarchy)');
assert(/font-size\s*:\s*var\(--fs-xl\)/.test(pctBlock),
'.compare-strip-mid-pct must be var(--fs-xl) (the largest)');
});
test('.compare-strip-mid-count is strictly smaller than .compare-strip-mid-pct (token-rank)', () => {
const countBlock = ruleBlock(CSS, /\.compare-strip-mid-count(?!-)/);
const pctBlock = ruleBlock(CSS, /\.compare-strip-mid-pct(?!-)/);
assert(countBlock, '.compare-strip-mid-count rule missing');
assert(pctBlock, '.compare-strip-mid-pct rule missing');
const cRank = fsRank(fsOf(countBlock));
const pRank = fsRank(fsOf(pctBlock));
assert(cRank != null && pRank != null, `could not parse font-sizes (count=${fsOf(countBlock)}, pct=${fsOf(pctBlock)})`);
assert(cRank < pRank,
`.compare-strip-mid-count rank (${cRank}) must be < .compare-strip-mid-pct rank (${pRank})`);
});
// ── 5) Compare CTA dead — button removed from DOM ─────────────────────
test('legacy #compareBtn is no longer emitted by compare.js (auto-run replaces it)', () => {
// Behavioral: the markup string for an explicit Compare button must
// be gone from the rendered HTML. A render path that conditionally
// hides a still-present button would let an enabled-but-hidden state
// exist; deleting the markup makes that impossible.
const re = /id=["']compareBtn["']/;
assert(!re.test(COMPARE_JS),
'compare.js still renders #compareBtn — must be removed (auto-run on selection change)');
// And no .compare-btn-ghost / .compare-btn class lingers.
assert(!/compare-btn-ghost/.test(COMPARE_JS),
'dead .compare-btn-ghost class still emitted');
});
// ── 6) decorative asym-line border-left explicitly removed ────────────
test('.compare-asym-line declares border-left: none AFTER any border-shorthand (cascade-safe)', () => {
const block = ruleBlock(CSS, /\.compare-asym-line(?!-)/);
assert(block, '.compare-asym-line rule missing');
// Tightened (Kent Beck must-fix #3): require explicit border-left: none,
// not just absence of a border-left: declaration. If a border: shorthand
// exists, border-left: none must come AFTER it so the cascade kills the
// left edge.
const blIdx = block.search(/border-left\s*:\s*none\b/);
assert(blIdx >= 0,
'.compare-asym-line must explicitly declare `border-left: none` (a future `border:` shorthand could re-add the bar)');
const shortIdx = block.search(/(?:^|[;{\s])border\s*:\s*(?!none\b)[^;]*\b\d/);
if (shortIdx >= 0) {
assert(blIdx > shortIdx,
'.compare-asym-line `border-left: none` must come AFTER `border:` shorthand or shorthand wins');
}
});
// ── 7) decorative type-summary border-left explicitly removed ─────────
test('.compare-type-summary declares border-left: none AFTER any border-shorthand (cascade-safe)', () => {
const block = ruleBlock(CSS, /\.compare-type-summary(?!-)/);
assert(block, '.compare-type-summary rule missing');
const blIdx = block.search(/border-left\s*:\s*none\b/);
assert(blIdx >= 0,
'.compare-type-summary must explicitly declare `border-left: none`');
const shortIdx = block.search(/(?:^|[;{\s])border\s*:\s*(?!none\b)[^;]*\b\d/);
if (shortIdx >= 0) {
assert(blIdx > shortIdx,
'.compare-type-summary `border-left: none` must come AFTER `border:` shorthand');
}
});
// ── 8) controls collapse — assert the actual DOM call, not comment text ──
test('compare.js makes the actual classList.toggle("is-collapsed", ready) call AND CSS keys on it', () => {
// Tightened (Kent Beck must-fix #1, Adversarial #6, #8): assert the
// behavioral call, not just a string match against comments. The PR
// also dropped the redundant data-collapsed setAttribute path, so any
// re-introduction is a regression.
const callRe = /classList\.toggle\(\s*['"]is-collapsed['"]\s*,/;
assert(callRe.test(COMPARE_JS),
'compare.js must call classList.toggle("is-collapsed", <bool>) on #compareControls');
assert(!/setAttribute\(\s*['"]data-collapsed['"]/.test(COMPARE_JS),
'compare.js must NOT also setAttribute("data-collapsed", ...) — pick one source of truth (the class)');
// CSS rule keying on the class must exist (without a stale [data-collapsed] selector).
assert(/\.compare-controls\.is-collapsed/.test(CSS),
'style.css must define a rule on .compare-controls.is-collapsed');
});
// ════════════════════════════════════════════════════════════════════
// #1646 — Mobile follow-ups (Tufte review).
// At ≤768px the page sits above a fixed bottom-nav (56px + safe-area)
// reserved via --bottom-nav-reserve. The headline diff bar has segments
// that go invisible at narrow widths when their share is ~2%, and the
// asymmetric-reach sentences wrap mid-phrase. These are behavioral
// guards so the polish doesn't quietly regress.
// ════════════════════════════════════════════════════════════════════
// helper: pull the body of the FIRST @media (max-width: <=768px) block
function mobileBlock(css) {
const re = /@media[^{]*\(max-width:\s*(640|768)px\)\s*\{([\s\S]*?)\n\}\s*\n/g;
let combined = '';
let m;
while ((m = re.exec(css))) combined += m[2] + '\n';
return combined;
}
const MOBILE_CSS = mobileBlock(CSS);
// ── 9) compare-page reserves room for the bottom-nav at mobile ────────
test('mobile .compare-page reserves padding-bottom for the bottom-nav', () => {
// Either the rule lives inside a mobile @media block, OR an
// unconditional rule references var(--bottom-nav-reserve).
const inMobile = /\.compare-page[^{]*\{[^}]*padding-bottom[^;]*--bottom-nav-reserve/m.test(MOBILE_CSS);
const unconditional = /\.compare-page[^{]*\{[^}]*padding-bottom[^;]*--bottom-nav-reserve/m.test(CSS);
assert(inMobile || unconditional,
'.compare-page must add padding-bottom tied to var(--bottom-nav-reserve) so the last row is not eaten by the bottom-nav');
});
// ── 10) diff-bar segments stay visible at narrow widths ───────────────
test('.compare-bar-seg has a min-width so non-zero segments stay visible', () => {
// We accept either a global min-width on .compare-bar-seg, or a
// mobile-scoped one. Visibility is what matters.
const reAny = /\.compare-bar-seg[^{}]*\{[^}]*min-width\s*:/m;
assert(reAny.test(CSS),
'.compare-bar-seg must declare min-width so a 2% segment is still readable on mobile');
// #1646 round-2 review: the floor must be a *presence* floor only,
// not a magnitude-flattening floor. 6px collapses 1% and 5% slices
// to identical width, lying about magnitude. Cap at 2px — visible
// pip without falsely equating sizes.
const segBlockRe = /\.compare-bar-seg[^{}]*\{[^}]*min-width\s*:\s*(\d+)px/m;
const m = CSS.match(segBlockRe);
assert(m, '.compare-bar-seg min-width must be expressed in px so we can bound it');
const px = parseInt(m[1], 10);
assert(px <= 2,
`.compare-bar-seg min-width must be <= 2px (presence floor only, not magnitude-equivalent); got ${px}px`);
});
// ── 11) asym sentence reflows cleanly on mobile ───────────────────────
test('mobile .compare-asym-line uses text-wrap balance/pretty (or overflow-wrap) to avoid mid-phrase breaks', () => {
// Look inside any mobile @media block for the rule. We accept
// text-wrap: balance | pretty, or word-break/overflow-wrap as
// alternative reflow strategies.
const ok = /\.compare-asym-line[^{}]*\{[^}]*(text-wrap\s*:\s*(balance|pretty)|overflow-wrap\s*:\s*anywhere|word-break\s*:\s*break-word)/m.test(MOBILE_CSS);
assert(ok,
'mobile .compare-asym-line needs a wrap rule (text-wrap: balance/pretty or overflow-wrap) so the sentence does not break mid-phrase');
});
// ── 12) tabs row stays usable on narrow widths ────────────────────────
test('mobile .compare-tabs .tab-btn shrinks/wraps so all four tabs fit', () => {
// We need EITHER tabs to allow wrap (flex-wrap: wrap on .compare-tabs
// — already present at desktop) AND a per-button rule that lets the
// long "Only <name> (NNN)" labels truncate or shrink.
// Accept: (a) a mobile rule on .tab-btn with min-width:0 + text-overflow,
// (b) a flex: 1 1 0 / flex-shrink, or
// (c) overflow-wrap on the tab.
const ok = /\.compare-tabs[^{}]*\.tab-btn[^{}]*\{[^}]*(min-width\s*:\s*0|flex\s*:\s*1|flex-shrink|overflow-wrap)/m.test(MOBILE_CSS) ||
/\.compare-tabs\s+\.tab-btn[^{}]*\{[^}]*(min-width\s*:\s*0|flex\s*:\s*1|flex-shrink|overflow-wrap)/m.test(MOBILE_CSS);
assert(ok,
'mobile .compare-tabs .tab-btn must declare min-width:0 / flex / overflow-wrap so all four tab labels fit at 375px');
});
// ── 13) compare.js auto-run guards are consistent (#1646 round-2) ─────
// Round-2 review found two run-comparison call sites with *different*
// guards: loadObservers used `selA && selB` (no inequality check) while
// onChange used `selA && selB && selA !== selB`. URL-prepopulated
// ?a=X&b=X (same observer in both slots) would launch a comparison via
// the loadObservers path and not the change path. Both call sites must
// gate on the same predicate.
test('compare.js — both runComparison() guards include selA !== selB', () => {
// Find every line that calls runComparison() and check the guard
// immediately preceding it on the same line is identical.
// Only inspect guards that reference the selA/selB / ready predicate
// (not the route-filter re-run path which conditions on comparisonResult).
const guardLines = COMPARE_JS
.split('\n')
.filter(l => /\brunComparison\s*\(/.test(l) && /\bif\s*\(/.test(l))
.filter(l => /selA|selB|[Rr]eady\b|canCompare\b/.test(l));
assert(guardLines.length >= 2,
`expected at least 2 selA/selB-guarded runComparison() call sites, found ${guardLines.length}`);
// Every such line must check selA !== selB (or the equivalent
// ready predicate factored into a helper). The forbidden pattern is
// a guard that ONLY checks `selA && selB` without the inequality.
guardLines.forEach((line, i) => {
const trimmed = line.trim();
const hasInequality = /selA\s*!==\s*selB|selB\s*!==\s*selA|[Rr]eady\b|canCompare\b/.test(trimmed);
assert(hasInequality,
`runComparison() guard #${i + 1} missing selA !== selB / ready predicate: ${trimmed}`);
});
});
process.exit(failed === 0 ? 0 : 1);