mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-22 07:51:44 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0addfdabf | |||
| f06359d739 | |||
| b0996047ef | |||
| ef13b22291 | |||
| bb3fd21f9f | |||
| e3a3f93f7b |
@@ -1,17 +1,17 @@
|
||||
# CoreScope v3.9.0
|
||||
|
||||
**Upgrade urgency: Medium** — fixes the post-restart "timeline empty for relays" regression, lands the Compare UX redesign, and migrates every UI surface from emoji to Phosphor sprite icons.
|
||||
**Upgrade urgency: Medium** — fixes the post-restart "relay timelines empty" regression, surfaces silent `/api/nodes` truncation, and ships operator-controlled per-name hiding.
|
||||
|
||||
_257 commits since v3.8.3 (72 substantive + 185 auto-generated coverage bumps). Every bullet ends with a commit SHA — `git show <sha>` to verify._
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Relay timelines come back after a restart.** Cold-loading the ingestor was leaving relay nodes with empty hop histories until fresh traffic arrived; the relay-hop attribution index is now rebuilt from `path_json` on startup, so per-relay timelines, hop counts, and route stats survive a restart instead of waiting for replay. (#1643, 938153dd)
|
||||
- **Observer Compare is now a first-class UX.** Three new entry points (header CTA, sticky selector strip, observer-table multi-select) feed a Tufte-grade compare page with state-preserving multi-select and themed button vocabulary — was a hidden URL trick before. (#1642, 531bc8ac; #1645, c93ae67e; #1647, 167af54e)
|
||||
- **Every emoji glyph in the UI is now a Phosphor sprite.** Six milestones (top-nav, page headers, detail panes, map overlays, settings/customize, lint-gate sweep) replaced every UI emoji with theme-tinted Phosphor icons — consistent stroke, no font-render variance across platforms, lint-gated against regressions. (#1649, 55e4d957; #1650, 30627454; #1651, b812a98a; #1652, 2b6809cd; #1653, 1116801b; #1654, 89eade6e)
|
||||
- **Per-node Reach page.** New `/api/nodes/{pubkey}/reach` + UI surfaces directional link quality per neighbor with response-cache invalidation on blacklist changes. (#1627, e2212f50)
|
||||
- **Hashtag channels catalogue is wired in.** Public hashtag channels from `meshcore-channels` now show up natively in the channels list — no more manual catalogue maintenance. (#1656, e04c7113)
|
||||
- **Operator-customizable name-prefix hiding.** New `hiddenNamePrefixes` config (default `["🚫"]`) drops matching nodes from `/api/nodes*` while preserving DB rows for analytics — mirrors the convention other MeshCore dashboards already use. (#1655, 825b2648)
|
||||
- **Your relay timelines survive a restart.** Before v3.9.0, every container restart left repeater nodes with empty hop histories until live traffic replayed enough adverts to re-attribute. Now the relay-hop index is rebuilt from `path_json` during cold load — per-relay timelines, hop counts, and route stats are intact the moment the server says it's ready. (#1643, 938153dd)
|
||||
- **`/api/nodes` stops silently truncating at 500 rows.** The hard cap was hiding nodes from the map, analytics and packets pages on any mesh of meaningful size — without any warning. Now properly paginated across every consumer, with internal UI requests bypassing the per-page clamp. (#1607, 26105748; #1637, 9002b25b; #1589, 7421ead9)
|
||||
- **Hide your own node from a public dashboard with a prefix rename.** New `hiddenNamePrefixes` config (default `["🚫"]`) drops matching nodes from `/api/nodes*` while keeping DB rows for analytics — same convention other MeshCore dashboards already follow, no DB surgery, no permanent loss of history. (#1655, 825b2648)
|
||||
- **Observer Compare is finally discoverable.** The compare page existed before but was a hidden URL trick; now there are three entry points (header CTA, sticky selector strip, observer-table multi-select) leading into a Tufte-grade compare view with state-preserving selection. (#1642, 531bc8ac; #1645, c93ae67e; #1647, 167af54e)
|
||||
- **Per-node Reach.** New `/api/nodes/{pubkey}/reach` + UI surfaces directional link quality per neighbor — answers "is my link to X any good in both directions" without staring at a topology graph. (#1627, e2212f50)
|
||||
|
||||
|
||||
## What's New
|
||||
|
||||
@@ -146,7 +146,10 @@ Test plan: `workspace-meshcore/test-plans/v3.9.0-cdp-test-plan.md` (93 tests acr
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- **@efiten** — relay-attribution rebuild on cold-load (#1643).
|
||||
External contributors made this release:
|
||||
|
||||
- **@efiten** — relay-attribution rebuild on cold-load (#1643), paginate `/api/nodes` (#1637), per-node Reach page (#1627), MQTT subscribe-before-maintenance (#1609), remove dead backfill flag (#1583), plus #1625/#1626 (per-node Reach relanding).
|
||||
- **@EldoonNemar** — OSM / Stamen tile provider support (#1533), `Cache-Control: no-store` follow-up (#1580), internal-bypass for API limit clamps (#1589), reliable row-focus restoration on panel close (#1602).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+97
-9
@@ -96,6 +96,67 @@
|
||||
* further below — DO NOT add component CSS in this region.
|
||||
* ============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
* TIER-1 PALETTE — raw colour stops. Theme-independent.
|
||||
* Issue #1671 / #1668 M2. Single source of truth for every hex
|
||||
* value that ships in this file. Outside this block, no rule
|
||||
* may use a raw `#hex` or `rgb(...)` literal — use a token.
|
||||
* Source: Tailwind CSS v3 default palette (MIT licensed,
|
||||
* battle-tested for WCAG-graded luminance steps).
|
||||
* gray ×9 blue ×9 green ×5
|
||||
* amber ×5 red ×5 purple ×5
|
||||
* Total palette tokens: 38.
|
||||
* ============================================================ */
|
||||
:root {
|
||||
/* Grays — 9 stops, low → high luminance */
|
||||
--palette-gray-50: #f9fafb;
|
||||
--palette-gray-100: #f3f4f6;
|
||||
--palette-gray-200: #e5e7eb;
|
||||
--palette-gray-300: #d1d5db;
|
||||
--palette-gray-400: #9ca3af;
|
||||
--palette-gray-500: #6b7280;
|
||||
--palette-gray-600: #4b5563;
|
||||
--palette-gray-700: #374151;
|
||||
--palette-gray-800: #1f2937;
|
||||
--palette-gray-900: #111827;
|
||||
|
||||
/* Blues — accent / info family */
|
||||
--palette-blue-100: #dbeafe;
|
||||
--palette-blue-200: #bfdbfe;
|
||||
--palette-blue-300: #93c5fd;
|
||||
--palette-blue-400: #60a5fa;
|
||||
--palette-blue-500: #3b82f6;
|
||||
--palette-blue-600: #2563eb;
|
||||
--palette-blue-700: #1d4ed8;
|
||||
--palette-blue-800: #1e40af;
|
||||
--palette-blue-900: #1e3a8a;
|
||||
|
||||
/* Greens / ambers / reds / purples — status families */
|
||||
--palette-green-300: #86efac;
|
||||
--palette-green-400: #4ade80;
|
||||
--palette-green-500: #22c55e;
|
||||
--palette-green-600: #16a34a;
|
||||
--palette-green-700: #15803d;
|
||||
|
||||
--palette-amber-300: #fcd34d;
|
||||
--palette-amber-400: #fbbf24;
|
||||
--palette-amber-500: #f59e0b;
|
||||
--palette-amber-600: #d97706;
|
||||
--palette-amber-700: #b45309;
|
||||
|
||||
--palette-red-300: #fca5a5;
|
||||
--palette-red-400: #f87171;
|
||||
--palette-red-500: #ef4444;
|
||||
--palette-red-600: #dc2626;
|
||||
--palette-red-700: #b91c1c;
|
||||
|
||||
--palette-purple-300: #c4b5fd;
|
||||
--palette-purple-400: #a78bfa;
|
||||
--palette-purple-500: #8b5cf6;
|
||||
--palette-purple-600: #7c3aed;
|
||||
--palette-purple-700: #6d28d9;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Node-quality link strength colours (bottleneck tiers). Dark-theme
|
||||
overrides live in the [data-theme="dark"] block below (brighter hues). */
|
||||
@@ -145,6 +206,14 @@
|
||||
--nav-text-muted: #cbd5e1;
|
||||
--nav-active-bg: rgba(74, 158, 255, 0.15);
|
||||
--accent: #4a9eff;
|
||||
/* #1668 M2 — accessible accent for chips/badges/active buttons that put
|
||||
white text on a blue surface. The legacy --accent (#4a9eff) yields
|
||||
#fff/#4a9eff = 2.75:1 (BLOCKER per M1 audit). --accent-strong drops
|
||||
two stops to palette-blue-600 = #2563eb (#fff on it = 4.83:1, AA pass)
|
||||
and is the surface for: .skip-link, .btn.active, .hop-link.hop-named,
|
||||
.nav-link.active background, .fGroup.active. */
|
||||
--accent-strong: var(--palette-blue-600);
|
||||
--text-on-accent: var(--palette-gray-50);
|
||||
--accent-bg: rgba(59, 130, 246, 0.12);
|
||||
--accent-border: rgba(59, 130, 246, 0.25);
|
||||
--geo-filter-color: #3b82f6;
|
||||
@@ -164,7 +233,11 @@
|
||||
--role-observer: #8b5cf6;
|
||||
--accent-hover: #6db3ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #5b6370;
|
||||
/* #1668 M2 — bumped from #5b6370 (~5.7:1 on white) to palette-gray-700
|
||||
#374151 (~10.3:1 on white, ~9.9:1 on surface-0 #f4f5f7). Light theme
|
||||
muted text was a MAJOR/borderline case; this clears the buffer. */
|
||||
--text-muted: var(--palette-gray-700);
|
||||
--text-subtle: var(--palette-gray-600);
|
||||
--border: #e2e5ea;
|
||||
--row-stripe: #f9fafb;
|
||||
--row-hover: #eef2ff;
|
||||
@@ -270,7 +343,12 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-2);
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #a8b8cc;
|
||||
/* #1668 M2 — text-muted bumped from #a8b8cc to palette-gray-300
|
||||
(#d1d5db); ~8.7:1 on surface-0, ~7.6:1 on card-bg (was ~7.5:1).
|
||||
Clears `span.text-muted` BLOCKERs flagged on translucent surfaces
|
||||
(where the legacy value dropped below 4.5 once alpha-stacked). */
|
||||
--text-muted: var(--palette-gray-300);
|
||||
--text-subtle: var(--palette-gray-400);
|
||||
--border: #334155;
|
||||
--row-stripe: #1e1e34;
|
||||
--row-hover: #2d2d50;
|
||||
@@ -308,7 +386,9 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-2);
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #a8b8cc;
|
||||
/* #1668 M2 — see :root[data-theme="dark"] media block for rationale */
|
||||
--text-muted: var(--palette-gray-300);
|
||||
--text-subtle: var(--palette-gray-400);
|
||||
--border: #334155;
|
||||
--row-stripe: #1e1e34;
|
||||
--row-hover: #2d2d50;
|
||||
@@ -331,7 +411,10 @@ html, body { height: 100%; font-family: var(--font); font-size: var(--fs-md); ba
|
||||
* ============================================================ */
|
||||
|
||||
/* === Skip Link === */
|
||||
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent); color: #fff; border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
|
||||
/* #1668 M2 — was background:var(--accent) (#4a9eff) with white text =
|
||||
2.75:1 BLOCKER. Bumped to --accent-strong / --text-on-accent for
|
||||
4.83:1 AA pass (both themes). */
|
||||
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent-strong); color: var(--text-on-accent); border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
|
||||
.skip-link:focus { top: 8px; }
|
||||
|
||||
/* === Focus Indicators === */
|
||||
@@ -868,7 +951,7 @@ img.brand-logo {
|
||||
* widths instead of letting individual controls reflow across categories. */
|
||||
.filter-bar .filter-group { flex-wrap: nowrap; }
|
||||
.filter-group .btn { padding: 4px 10px; font-size: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); cursor: pointer; transition: background 0.15s, color 0.15s; height: 34px; min-height: 34px; box-sizing: border-box; line-height: 1; }
|
||||
.filter-group .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.filter-group .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
|
||||
.filter-group .btn:hover:not(.active) { background: var(--surface-2); }
|
||||
.filter-group + .filter-group { border-left: 1px solid var(--border); padding-left: 12px; margin-left: 6px; }
|
||||
.sort-help { cursor: help; font-size: 14px; color: var(--text-muted, #888); position: relative; display: inline-block; }
|
||||
@@ -881,7 +964,7 @@ img.brand-logo {
|
||||
}
|
||||
.sort-help:hover .sort-help-tip { display: block; }
|
||||
.filter-bar .btn:hover { background: var(--row-hover); }
|
||||
.filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
|
||||
.filter-bar .col-toggle-btn { height: 34px; min-height: 34px; }
|
||||
|
||||
.btn-icon {
|
||||
@@ -1350,7 +1433,11 @@ body.scroll-locked { overflow: hidden; }
|
||||
vertical-align: middle;
|
||||
}
|
||||
.path-hops .hop { color: var(--accent); line-height: 18px; }
|
||||
.path-hops .hop-named { color: #fff; background: var(--accent); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
|
||||
/* #1668 M2 / operator-flagged chip: was `color:#fff; background:var(--accent)`
|
||||
= 2.75:1 on dark theme (BLOCKER). Now uses --accent-strong (#2563eb)
|
||||
with --text-on-accent (gray-50) for 4.83:1 — WCAG AA body pass in
|
||||
both themes. */
|
||||
.path-hops .hop-named { color: var(--text-on-accent); background: var(--accent-strong); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
|
||||
.path-hops .arrow { color: var(--text-muted); flex: 0 0 auto; line-height: 18px; }
|
||||
/* #1122/#1128: bound the row height contributed by the path column.
|
||||
* `max-height` on a <td> is widely ignored by browsers (table layout
|
||||
@@ -1940,7 +2027,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
[data-theme="dark"] .trace-search input,
|
||||
[data-theme="dark"] .mc-jump-btn,
|
||||
[data-theme="dark"] .filter-bar .btn { background: var(--input-bg); color: var(--text); border-color: var(--border); }
|
||||
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
|
||||
[data-theme="dark"] .ch-item.selected,
|
||||
[data-theme="dark"] .data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
[data-theme="dark"] .tl-bar-container { background: #334155; }
|
||||
@@ -2639,7 +2726,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.analytics-tabs { display: flex; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.tab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: all .15s; }
|
||||
.tab-btn:hover { background: var(--hover-bg, rgba(0,0,0,.04)); }
|
||||
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* #1668 M2 — active tab was #fff on --accent (2.75:1 BLOCKER). */
|
||||
.tab-btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: var(--text); }
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/* Issue #1668 M2 / #1671 — Palette indirection + WCAG AA token bumps.
|
||||
*
|
||||
* This test:
|
||||
* 1. Parses public/style.css and extracts CSS custom-property values per
|
||||
* theme (:root = light, [data-theme="dark"] = dark).
|
||||
* 2. Resolves var(...) indirection (one level deep is enough for our
|
||||
* two-tier palette → semantic mapping).
|
||||
* 3. Computes WCAG relative-luminance contrast ratios for the foreground/
|
||||
* background pairs that were flagged as BLOCKER in the M1 a11y audit
|
||||
* (a11y-audit/reports/violations-summary.md).
|
||||
* 4. Asserts each pair meets WCAG AA (≥4.5:1 for body text).
|
||||
*
|
||||
* Source for contrast formula: https://www.w3.org/WAI/WCAG21/Techniques/general/G18
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
const CSS_PATH = 'public/style.css';
|
||||
const css = fs.readFileSync(CSS_PATH, 'utf8');
|
||||
|
||||
// ── Token extraction ──────────────────────────────────────────────────────
|
||||
function extractBlockTokens(blockRegex) {
|
||||
const tokens = {};
|
||||
// Use a /g regex; same selector may appear in multiple blocks (e.g. two
|
||||
// `:root { ... }` blocks: palette + semantic). Later definitions win,
|
||||
// mirroring CSS cascade order.
|
||||
const flagged = new RegExp(blockRegex.source, blockRegex.flags.includes('g') ? blockRegex.flags : blockRegex.flags + 'g');
|
||||
let m;
|
||||
while ((m = flagged.exec(css)) !== null) {
|
||||
const body = m[1];
|
||||
const re = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim;
|
||||
let mm;
|
||||
while ((mm = re.exec(body)) !== null) {
|
||||
tokens[mm[1]] = mm[2].trim();
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Light theme = :root block (the FIRST :root, lines ~99-247)
|
||||
const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/);
|
||||
// Dark theme = [data-theme="dark"] block
|
||||
const darkTokens = extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/);
|
||||
|
||||
function resolveToken(name, theme) {
|
||||
const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens;
|
||||
let val = map[name];
|
||||
if (!val) return null;
|
||||
// resolve up to 5 levels of var() indirection
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^)]+))?\)\s*$/);
|
||||
if (!m) break;
|
||||
const next = map[m[1]];
|
||||
val = next || (m[2] ? m[2].trim() : null);
|
||||
if (!val) return null;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// ── Color parsing + contrast ──────────────────────────────────────────────
|
||||
function parseColor(s) {
|
||||
if (!s) return null;
|
||||
s = s.trim();
|
||||
// #rgb / #rrggbb
|
||||
let m = s.match(/^#([0-9a-f]{3})$/i);
|
||||
if (m) {
|
||||
return [
|
||||
parseInt(m[1][0] + m[1][0], 16),
|
||||
parseInt(m[1][1] + m[1][1], 16),
|
||||
parseInt(m[1][2] + m[1][2], 16),
|
||||
];
|
||||
}
|
||||
m = s.match(/^#([0-9a-f]{6})$/i);
|
||||
if (m) {
|
||||
return [parseInt(m[1].slice(0,2),16), parseInt(m[1].slice(2,4),16), parseInt(m[1].slice(4,6),16)];
|
||||
}
|
||||
m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
||||
if (m) return [+m[1], +m[2], +m[3]];
|
||||
return null;
|
||||
}
|
||||
|
||||
function relLum([r,g,b]) {
|
||||
const f = (c) => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
|
||||
}
|
||||
|
||||
function contrast(fg, bg) {
|
||||
const L1 = relLum(fg), L2 = relLum(bg);
|
||||
const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
function ratioFromTokens(fgToken, bgToken, theme) {
|
||||
const fg = parseColor(resolveToken(fgToken, theme));
|
||||
const bg = parseColor(resolveToken(bgToken, theme));
|
||||
assert.ok(fg, `token ${fgToken} (${theme}) did not resolve to a color: got ${resolveToken(fgToken, theme)}`);
|
||||
assert.ok(bg, `token ${bgToken} (${theme}) did not resolve to a color: got ${resolveToken(bgToken, theme)}`);
|
||||
return { fg, bg, ratio: contrast(fg, bg) };
|
||||
}
|
||||
|
||||
// ── Palette indirection: existence assertions (closes #1671) ─────────────
|
||||
const PALETTE_PREFIXES = ['gray', 'blue', 'green', 'amber', 'red', 'purple'];
|
||||
for (const p of PALETTE_PREFIXES) {
|
||||
const re = new RegExp(`--palette-${p}-\\d+\\s*:`);
|
||||
assert.ok(re.test(css), `missing palette family --palette-${p}-* (closes #1671)`);
|
||||
}
|
||||
// At least 5 stops per family
|
||||
for (const p of PALETTE_PREFIXES) {
|
||||
const re = new RegExp(`--palette-${p}-\\d+\\s*:`, 'g');
|
||||
const n = (css.match(re) || []).length;
|
||||
assert.ok(n >= 5, `palette family --palette-${p}-* needs ≥5 stops, got ${n}`);
|
||||
}
|
||||
|
||||
// ── M1-BLOCKER contrast assertions ───────────────────────────────────────
|
||||
// Each row: [label, fgToken, bgToken, theme, minRatio]
|
||||
// AA body text = 4.5:1; large text (≥18px or ≥14px+700) = 3:1. Most flagged
|
||||
// surfaces are body text (11-13px @ 600), so 4.5:1 is the floor.
|
||||
const CASES = [
|
||||
// Operator-reported: .hop-named.hop-link chip — was #fff on var(--accent)
|
||||
// ≈ #4a9eff = 2.75:1. Must use --text-on-accent on --accent-strong (or
|
||||
// an equivalent darker blue) in BOTH themes.
|
||||
['hop-named chip (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
|
||||
['hop-named chip (light)', '--text-on-accent', '--accent-strong', 'light', 4.5],
|
||||
|
||||
// .skip-link / .btn.active — same #fff on --accent surface, also a BLOCKER
|
||||
// in M1. Bumping --accent-strong fixes them all. Verified via the same
|
||||
// token pair (they all rebind to --accent-strong in the patched CSS).
|
||||
['btn.active (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
|
||||
|
||||
// Body muted text on common surfaces.
|
||||
['text-muted on surface (dark)', '--text-muted', '--surface-1', 'dark', 4.5],
|
||||
['text-muted on content-bg (dark)', '--text-muted', '--surface-0', 'dark', 4.5],
|
||||
['text-muted on card-bg (dark)', '--text-muted', '--card-bg', 'dark', 4.5],
|
||||
['text-muted on surface (light)', '--text-muted', '--surface-1', 'light', 4.5],
|
||||
['text-muted on content-bg (light)', '--text-muted', '--surface-0', 'light', 4.5],
|
||||
|
||||
// Body text on the canonical page background.
|
||||
['text on content-bg (dark)', '--text', '--surface-0', 'dark', 7.0],
|
||||
['text on content-bg (light)', '--text', '--surface-0', 'light', 7.0],
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
console.log('\n#1668 M2 contrast audit\n' + '─'.repeat(60));
|
||||
for (const [label, fgT, bgT, theme, min] of CASES) {
|
||||
try {
|
||||
const { fg, bg, ratio } = ratioFromTokens(fgT, bgT, theme);
|
||||
const ok = ratio >= min;
|
||||
const fgHex = `#${fg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
|
||||
const bgHex = `#${bg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
|
||||
console.log(
|
||||
`${ok ? '✓' : '✗'} ${label.padEnd(42)} ${ratio.toFixed(2).padStart(5)}:1 (need ${min}) ${fgHex} on ${bgHex}`
|
||||
);
|
||||
if (!ok) failures++;
|
||||
assert.ok(ok, `${label}: contrast ${ratio.toFixed(2)}:1 < ${min}:1 (fg ${fgHex} on bg ${bgHex})`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`✗ ${label.padEnd(42)} ERROR: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
console.log('─'.repeat(60));
|
||||
console.log(failures === 0 ? `All ${CASES.length} contrast cases pass.` : `${failures} failure(s)`);
|
||||
@@ -70,24 +70,22 @@ const PAGES = [
|
||||
// virtual-scroll spacer race on the packets page (#1662).
|
||||
await page.waitForFunction((rowSel) => {
|
||||
return document.querySelector(rowSel) !== null;
|
||||
}, p.rowSel, { timeout: 8000 });
|
||||
}, p.rowSel, { timeout: 20000 });
|
||||
});
|
||||
|
||||
await step(`${tag}: clicking row opens slide-over with backdrop`, async () => {
|
||||
// Click the first body row — prefer one with a data-action attribute
|
||||
// (packets) or any row otherwise.
|
||||
const diag = await page.evaluate((sel) => {
|
||||
const diag = await page.evaluate(({sel, rowSel}) => {
|
||||
const t = document.querySelector(sel);
|
||||
if (!t) return { ok: false, why: 'no table' };
|
||||
const rows = t.querySelectorAll('tbody tr');
|
||||
// The packets table uses virtual scroll, so the FIRST DOM-order <tr>
|
||||
// is a spacer with no data-* attrs and no click handler. Skip those:
|
||||
// pick the first row that actually carries a delegated action.
|
||||
const candidates = Array.from(rows);
|
||||
// Use the page-specific rowSel so we never match the virtual-scroll
|
||||
// spacer (#1662). Don't fall through to bare 'tbody tr'.
|
||||
const candidates = Array.from(t.querySelectorAll(rowSel));
|
||||
const row = candidates.find(r => r.hasAttribute('data-action'))
|
||||
|| candidates.find(r => r.hasAttribute('data-value'))
|
||||
|| candidates.find(r => r.children.length > 0);
|
||||
if (!row) return { ok: false, why: 'no row', rowCount: rows.length };
|
||||
|| candidates[0];
|
||||
if (!row) return { ok: false, why: 'no row', rowCount: candidates.length };
|
||||
// Click a real cell (avoid empty/loading rows)
|
||||
const td = row.querySelector('td:not(:empty)') || row;
|
||||
// Dispatch a real bubbling click event so delegated tbody handlers fire.
|
||||
@@ -95,14 +93,14 @@ const PAGES = [
|
||||
td.dispatchEvent(ev);
|
||||
return {
|
||||
ok: true,
|
||||
rowCount: rows.length,
|
||||
rowCount: candidates.length,
|
||||
rowAction: row.getAttribute('data-action') || null,
|
||||
rowValue: row.getAttribute('data-value') || null,
|
||||
hasSlideOver: typeof window.SlideOver !== 'undefined',
|
||||
shouldUse: !!(window.SlideOver && window.SlideOver.shouldUse && window.SlideOver.shouldUse()),
|
||||
innerW: window.innerWidth,
|
||||
};
|
||||
}, p.tableSel);
|
||||
}, { sel: p.tableSel, rowSel: p.rowSel });
|
||||
if (!diag.ok) throw new Error('click setup failed: ' + JSON.stringify(diag));
|
||||
// Wait up to 15s for the slide-over to appear (packets does async fetches).
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user