Files
meshcore-analyzer/test-issue-1646-compare-polish.js
T
167af54eb8 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>
2026-06-11 05:44:00 +00:00

308 lines
16 KiB
JavaScript

/**
* 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);